From 01093feb6c08a0d6721aa364de5c8804392d4ce1 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 18:30:56 +0200 Subject: [PATCH 01/14] chore: add design spec for in-app car purchase flow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-31-car-purchase-flow-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md diff --git a/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md b/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md new file mode 100644 index 0000000000..b32006dc41 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md @@ -0,0 +1,158 @@ +# Car Purchase Flow - Design Spec + +## Goal + +Add in-app car insurance purchase flow, reusing the existing apartment purchase architecture. Extract shared purchase screens into a common module so both apartment and car flows share tier selection, summary, signing, success, and failure screens. + +## Module Structure + +### New modules + +#### `feature-purchase-common` + +Shared purchase screens, use cases, models, and GraphQL operations that are product-agnostic. + +**Screens:** +- `SelectTierDestination` + `SelectTierViewModel` — tier/deductible selection with grouped offers +- `PurchaseSummaryDestination` + `PurchaseSummaryViewModel` — selected offer details, triggers signing +- `SigningDestination` + `SigningViewModel` — BankID polling, QR code fallback +- `PurchaseSuccessDestination` — confirmation with optional start date +- `PurchaseFailureDestination` — error display with retry + +**Use cases:** +- `AddToCartAndStartSignUseCase` — calls `ShopSessionCartEntriesAdd` + `ShopSessionStartSign` +- `PollSigningStatusUseCase` — polls `ShopSessionSigning` with `NetworkOnly` fetch policy + +**Models:** +- `TierOfferData` — serializable offer data passed between screens (offerId, tier name/description, gross/net pricing, USPs, exposure, deductible, discount flag) +- `SelectTierParameters` — shopSessionId, offers list, productDisplayName +- `SummaryParameters` — shopSessionId, selectedOffer, productDisplayName +- `SigningParameters` — signingId, autoStartToken, startDate +- `SigningStart` — signingId, autoStartToken +- `SigningPollResult` — status, liveQrCodeData +- `SigningStatus` — enum: PENDING, SIGNED, FAILED + +**GraphQL operations (moved from apartment, drop `Apartment` prefix):** +- `ShopSessionCartEntriesAddMutation.graphql` +- `ShopSessionStartSignMutation.graphql` +- `ShopSessionSigningQuery.graphql` +- `ProductOfferFragment.graphql` + +**Build config:** +- Plugins: `hedvig.android.library`, `hedvig.gradle.plugin` +- Hedvig DSL: `apollo("octopus")`, `serialization()`, `compose()` +- Dependencies: same as current apartment module minus form-specific deps + +#### `feature-purchase-car` + +Car-specific form, use cases, navigation, and DI. + +**Screens:** +- `CarFormDestination` + `CarFormViewModel` — car insurance form + +**Form fields (matching racoon SE_CAR template):** +| Field | Type | Validation | Notes | +|-------|------|-----------|-------| +| SSN (personnummer) | Text, numeric | 12 digits (YYYYMMDDXXXX) | Swedish personal number | +| Registration number | Text | 3 letters + 2 digits + 1 alphanumeric (e.g. ABC 123) | Auto-uppercase, auto-space after 3rd char | +| Mileage | Dropdown | Required selection | Options: 1000, 1500, 2000, 2500, 2500+ (Scandinavian miles) | +| Street address | Text | Non-empty | | +| Zip code | Text, numeric | 5 digits | | +| Email | Text | Valid email format | | + +**Use cases:** +- `CreateCarSessionAndPriceIntentUseCase` — creates ShopSession + PriceIntent for `SE_CAR` +- `SubmitCarFormAndGetOffersUseCase` — submits form data map (`ssn`, `registrationNumber`, `mileage`, `street`, `zipCode`, `email`) via `PriceIntentDataUpdate`, then confirms via `PriceIntentConfirm` + +**GraphQL operations (car-prefixed for Apollo codegen isolation):** +- `CarShopSessionCreateMutation.graphql` — same schema as apartment, different operation name +- `CarPriceIntentCreateMutation.graphql` +- `CarPriceIntentDataUpdateMutation.graphql` +- `CarPriceIntentConfirmMutation.graphql` + +**Navigation:** +- `CarPurchaseGraphDestination(productName: String)` — entry point +- `CarPurchaseNavGraph` — wires: CarForm -> SelectTier -> Summary -> Signing -> Success/Failure +- All post-form destinations come from `feature-purchase-common` + +**DI:** +- `carPurchaseModule` — Koin module registering car-specific use cases and ViewModels + +### Modified modules + +#### `feature-purchase-apartment` + +Slimmed down — shared code extracted to common. + +**Keeps:** +- `ApartmentFormDestination` + `ApartmentFormViewModel` +- `CreateSessionAndPriceIntentUseCase` +- `SubmitFormAndGetOffersUseCase` +- `ApartmentPurchaseNavGraph` +- Apartment-specific GraphQL: `ShopSessionCreate`, `PriceIntentCreate`, `PriceIntentDataUpdate`, `PriceIntentConfirm` + +**Removes (moved to common):** +- `SelectTierDestination`, `SelectTierViewModel` +- `PurchaseSummaryDestination`, `PurchaseSummaryViewModel` +- `SigningDestination`, `SigningViewModel` +- `PurchaseSuccessDestination`, `PurchaseFailureDestination` +- `AddToCartAndStartSignUseCase`, `PollSigningStatusUseCase` +- Shared models (`TierOfferData`, `SigningParameters`, etc.) +- `ShopSessionCartEntriesAdd`, `ShopSessionStartSign`, `ShopSessionSigning`, `ProductOfferFragment` GraphQL files + +**Adds dependency on:** `feature-purchase-common` + +#### `feature-insurances` + +Update cross-sell routing to support both products: +- Currently hardcoded: `onNavigateToApartmentPurchase("SE_APARTMENT_RENT")` +- Add `onNavigateToCarPurchase: (productName: String) -> Unit` callback +- Route based on cross-sell product name prefix (`SE_APARTMENT_*` vs `SE_CAR*`) + +#### `app` (main application module) + +- Register `carPurchaseModule` in `ApplicationModule` +- Add `carPurchaseNavGraph()` call in `HedvigNavHost` +- Wire `onNavigateToCarPurchase` in insurances graph to navigate to `CarPurchaseGraphDestination` + +## Module Dependency Graph + +``` +feature-purchase-apartment ──> feature-purchase-common +feature-purchase-car ──────> feature-purchase-common +app ──> feature-purchase-apartment +app ──> feature-purchase-car +app ──> feature-purchase-common +``` + +Feature modules do not depend on each other — only on common (a library module). + +## Data Flow + +### Car Purchase Flow + +``` +1. User taps car cross-sell in insurances tab +2. Navigate to CarPurchaseGraphDestination(productName = "SE_CAR") +3. CarFormScreen loads: + a. CreateCarSessionAndPriceIntentUseCase creates ShopSession + PriceIntent + b. User fills: SSN, registration number, mileage, street, zip, email + c. SubmitCarFormAndGetOffersUseCase sends PriceIntentDataUpdate + PriceIntentConfirm + d. Returns list of TierOffer (mapped from ProductOfferFragment) +4. SelectTierScreen (shared): user picks coverage tier + deductible +5. PurchaseSummaryScreen (shared): user reviews and confirms +6. SigningScreen (shared): BankID signing with polling +7. PurchaseSuccessScreen (shared): confirmation +``` + +## Key Design Decisions + +1. **Separate GraphQL operation names per product** — Apollo generates Kotlin classes per operation name. Even though the mutations are identical in schema, each product module gets its own prefixed operations to avoid classpath conflicts. + +2. **Common module is a library, not a feature** — This lets both feature modules depend on it without violating the "features can't depend on features" build rule. + +3. **Form data is a `Map`** — The `PriceIntentDataUpdate` mutation accepts `PricingFormData!` which is a JSON scalar. Each product builds its own map with different keys. No shared form abstraction needed. + +4. **Shared screens are product-agnostic** — Tier selection, summary, signing, success, and failure screens operate purely on `TierOfferData`, `SummaryParameters`, and `SigningParameters`. They have no knowledge of apartment vs car. + +5. **Mileage as dropdown** — Matches racoon's CarMileageField implementation with fixed options (1000, 1500, 2000, 2500, 2500+) in Scandinavian miles. From 6c15913001ac735bfabde2a741ac82d5c53df2d0 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 18:40:25 +0200 Subject: [PATCH 02/14] chore: add implementation plan for car purchase flow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-31-car-purchase-flow.md | 2967 +++++++++++++++++ 1 file changed, 2967 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-car-purchase-flow.md diff --git a/docs/superpowers/plans/2026-03-31-car-purchase-flow.md b/docs/superpowers/plans/2026-03-31-car-purchase-flow.md new file mode 100644 index 0000000000..b209fd813e --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-car-purchase-flow.md @@ -0,0 +1,2967 @@ +# Car 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:** Extract shared purchase screens into `feature-purchase-common`, create `feature-purchase-car` with car-specific form, and wire both into the app navigation. + +**Architecture:** Three-module approach — shared screens/use-cases/models in `feature-purchase-common`, product-specific forms and GraphQL in `feature-purchase-apartment` and `feature-purchase-car`. Both product modules depend on common but not each other. + +**Tech Stack:** Kotlin, Jetpack Compose, Apollo GraphQL, Molecule (MVI), Koin DI, Arrow (Either), kotlinx.serialization, ZXing (QR codes) + +--- + +### Task 1: Create `feature-purchase-common` module with shared models and GraphQL + +**Files:** +- Create: `app/feature/feature-purchase-common/build.gradle.kts` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionStartSignMutation.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionSigningQuery.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ProductOfferFragment.graphql` + +- [ ] **Step 1: Create `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.apollo.normalizedCache) + 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(libs.zXing) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.designSystemHedvig) + 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 shared models** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.data + +data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +data class SigningPollResult( + val status: SigningStatus, + val liveQrCodeData: String?, +) + +enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} +``` + +- [ ] **Step 3: Create shared navigation models** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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 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 +data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) + +sealed interface PurchaseCommonDestination { + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : PurchaseCommonDestination, Destination + + @Serializable + data object Failure : PurchaseCommonDestination, Destination +} +``` + +- [ ] **Step 4: Create GraphQL operations** + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +```graphql +mutation PurchaseCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionStartSignMutation.graphql` +```graphql +mutation PurchaseStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionSigningQuery.graphql` +```graphql +query PurchaseShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ProductOfferFragment.graphql` +```graphql +fragment PurchaseProductOfferFragment 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 5: Commit** + +```bash +git add app/feature/feature-purchase-common/ +git commit -m "feat: scaffold feature-purchase-common module with shared models and GraphQL" +``` + +--- + +### Task 2: Move shared use cases to `feature-purchase-common` + +**Files:** +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt` + +- [ ] **Step 1: Create `AddToCartAndStartSignUseCase`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.PurchaseCartEntriesAddMutation +import octopus.PurchaseStartSignMutation + +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(PurchaseCartEntriesAddMutation(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(PurchaseStartSignMutation(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 2: Create `PollSigningStatusUseCase`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +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 +import com.hedvig.android.logger.logcat +import octopus.PurchaseShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +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(PurchaseShopSessionSigningQuery(signingId = signingId)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + val signing = result.shopSessionSigning + val status = when (signing.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__, + -> SigningStatus.PENDING + } + SigningPollResult( + status = status, + liveQrCodeData = signing.seBankidProperties?.liveQrCodeData, + ) + }, + ) + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/ +git commit -m "feat: add shared AddToCartAndStartSign and PollSigningStatus use cases" +``` + +--- + +### Task 3: Move shared screens to `feature-purchase-common` + +**Files:** +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt` + +- [ ] **Step 1: Move `SelectTierViewModel.kt`** + +Copy from `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt` — update package to `com.hedvig.android.feature.purchase.common.ui.offer` and update imports from `com.hedvig.android.feature.purchase.apartment.navigation.*` to `com.hedvig.android.feature.purchase.common.navigation.*`. Remove `internal` visibility from `SelectTierViewModel`, `SelectTierPresenter`, `TierGroup`, `DeductibleOption`, `SelectTierUiState`, and `SelectTierEvent` since they'll be consumed cross-module. + +Full file: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class SelectTierViewModel( + params: SelectTierParameters, +) : MoleculeViewModel( + 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 }, + ) + } +} + +class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { + 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.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, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } + } + } + + return SelectTierUiState( + tierGroups = lastState.tierGroups, + selectedTierName = selectedTierName, + selectedDeductibleByTier = selectedDeductibleByTier, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +data class TierGroup( + val tierDisplayName: String, + val tierDescription: String, + val usps: List, + val deductibleOptions: List, +) + +data class DeductibleOption( + val offerId: String, + val deductibleDisplayName: String, + val netAmount: Double, + val netCurrencyCode: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val hasDiscount: Boolean, +) + +data class SelectTierUiState( + val tierGroups: List, + val selectedTierName: String, + val selectedDeductibleByTier: Map, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters?, +) + +sealed interface SelectTierEvent { + data class SelectTier(val tierName: String) : SelectTierEvent + data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent +} +``` + +- [ ] **Step 2: Move `SelectTierDestination.kt`** + +Copy from apartment module, update package to `com.hedvig.android.feature.purchase.common.ui.offer`, update imports to use `com.hedvig.android.feature.purchase.common.navigation.SummaryParameters`. Remove `internal` visibility. + +Full file: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.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.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.common.navigation.SummaryParameters +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +@Composable +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) + } + } + SelectTierContent( + uiState = uiState, + navigateUp = navigateUp, + onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, + onSelectDeductible = { tierName, offerId -> + viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) + }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierContent( + uiState: SelectTierUiState, + navigateUp: () -> Unit = {}, + onSelectTier: (String) -> Unit = {}, + onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, + onContinue: () -> Unit = {}, +) { + HedvigScaffold( + navigateUp = 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, 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.tierGroups.lastIndex) { + Spacer(Modifier.height(12.dp)) + } + } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { onContinue() }, + enabled = uiState.selectedTierName.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun TierGroupCard( + tierGroup: TierGroup, + isSelected: Boolean, + selectedDeductibleId: String, + onSelectTier: () -> Unit, + onSelectDeductible: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } + HedvigCard( + onClick = onSelectTier, + borderColor = if (isSelected) { + HedvigTheme.colorScheme.signalGreenElement + } else { + HedvigTheme.colorScheme.borderSecondary + }, + modifier = modifier, + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = tierGroup.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + if (selectedOption != null) { + HedvigText( + text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), + style = HedvigTheme.typography.bodyLarge, + ) + } + } + AnimatedVisibility( + visible = isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + if (tierGroup.usps.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + for (usp in tierGroup.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, + ) + } + } + } + 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( + visible = !isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "V\u00e4lj ${tierGroup.tierDisplayName}", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + } +} + +private fun formatPrice(amount: Double, currencyCode: String): String { + @Suppress("DEPRECATION") + val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) + format.currency = Currency.getInstance(currencyCode) + format.maximumFractionDigits = 0 + return "${format.format(amount)}/m\u00e5n" +} +``` + +- [ ] **Step 3: Move `PurchaseSummaryViewModel.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = false, + signingToNavigate = null, + navigateToFailure = false, + ), + presenter = PurchaseSummaryPresenter( + summaryParameters, + addToCartAndStartSignUseCase, + ), +) + +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, + ) + } +} + +data class PurchaseSummaryUiState( + val params: SummaryParameters, + val isSubmitting: Boolean, + val signingToNavigate: SigningParameters?, + val navigateToFailure: Boolean, +) + +sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent +} +``` + +- [ ] **Step 4: Move `PurchaseSummaryDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.Surface +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData + +@Composable +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, + ) + 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 = { + HedvigText( + text = "Pris", + style = HedvigTheme.typography.bodySmall, + ) + }, + spaceBetween = 8.dp, + endSlot = { + 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, + ) + } + }, + ) + } + } + 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)) + } +} +``` + +- [ ] **Step 5: Move `SigningViewModel.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.SigningStatus +import com.hedvig.android.feature.purchase.common.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 + +class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = null, + bankIdOpened = false, + ), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), +) + +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 -> {} + } + } + + LaunchedEffect(Unit) { + while (true) { + pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( + ifLeft = { + currentState = SigningUiState.Failed + return@LaunchedEffect + }, + ifRight = { pollResult -> + when (pollResult.status) { + SigningStatus.SIGNED -> { + currentState = SigningUiState.Success(startDate = signingParameters.startDate) + return@LaunchedEffect + } + + SigningStatus.FAILED -> { + currentState = SigningUiState.Failed + return@LaunchedEffect + } + + SigningStatus.PENDING -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = pollResult.liveQrCodeData, + bankIdOpened = bankIdOpened, + ) + } + } + }, + ) + delay(2_000) + } + } + + return when (val state = currentState) { + is SigningUiState.Polling -> state.copy(bankIdOpened = bankIdOpened) + else -> currentState + } + } +} + +sealed interface SigningUiState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val liveQrCodeData: String?, + val bankIdOpened: Boolean, + ) : SigningUiState + + data class Success(val startDate: String?) : SigningUiState + + data object Failed : SigningUiState +} + +sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent +} +``` + +- [ ] **Step 6: Move `SigningDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt` + +Same as existing file but with package `com.hedvig.android.feature.purchase.common.ui.sign`, public visibility, and imports updated to reference `com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel`, etc. + +```kotlin +package com.hedvig.android.feature.purchase.common.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.lifecycle.compose.collectAsStateWithLifecycle +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 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 +fun SigningDestination( + viewModel: SigningViewModel, + navigateToSuccess: (startDate: String?) -> Unit, + navigateToFailure: () -> Unit, +) { + 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 -> { + 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, + is SigningUiState.Failed, + -> HedvigFullScreenCenterAlignedProgress() + } +} + +@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\u00e5 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 = "\u00d6ppna BankID", + onClick = onOpenBankId, + enabled = true, + 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" +``` + +- [ ] **Step 7: Move `PurchaseSuccessDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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 +fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + itemsColumnHorizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + HedvigText( + text = "Din f\u00f6rs\u00e4kring \u00e4r 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\u00e4ng", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = true, + onClick = close, + ) + Spacer(Modifier.height(16.dp)) + } +} +``` + +- [ ] **Step 8: Move `PurchaseFailureDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.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 +fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + ) { + HedvigErrorSection( + onButtonClick = onRetry, + modifier = Modifier.weight(1f), + ) + } +} +``` + +- [ ] **Step 9: Create DI module for common** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.di + +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val purchaseCommonModule = module { + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + SelectTierViewModel(params = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} +``` + +- [ ] **Step 10: Commit** + +```bash +git add app/feature/feature-purchase-common/ +git commit -m "feat: add shared screens, use cases, and DI to feature-purchase-common" +``` + +--- + +### Task 4: Update `feature-purchase-apartment` to use common module + +**Files:** +- Modify: `app/feature/feature-purchase-apartment/build.gradle.kts` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt` +- Delete: All shared files that moved to common (offer/, summary/, sign/, success/, failure/ directories, AddToCartAndStartSignUseCase, PollSigningStatusUseCase, shared GraphQL files) + +- [ ] **Step 1: Add common dependency to build.gradle.kts** + +Add `implementation(projects.featurePurchaseCommon)` to dependencies. Remove `libs.zXing` (now in common). + +- [ ] **Step 2: Simplify `ApartmentPurchaseDestination.kt`** + +Remove all shared types (TierOfferData, SelectTierParameters, SummaryParameters, SigningParameters) — these now come from common. Keep only ApartmentPurchaseGraphDestination and internal apartment-specific destinations (Form only, plus re-exports from common for nav graph use). + +New contents: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination +} +``` + +- [ ] **Step 3: Update `ApartmentPurchaseNavGraph.kt`** + +Replace all apartment-specific imports for shared screens/models with imports from `com.hedvig.android.feature.purchase.common.*`. The nav graph now uses `PurchaseCommonDestination.*` for SelectTier, Summary, Signing, Success, Failure destinations, and `TierOfferData`, `SelectTierParameters`, `SummaryParameters`, `SigningParameters` from common navigation. + +```kotlin +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.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.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 = ApartmentPurchaseDestination.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() + }, + ) + } + } + // NOTE: Success destination is registered once in HedvigNavHost, not here. + // Both apartment and car nav graphs navigate to the same PurchaseCommonDestination.Success. +} +``` + +- [ ] **Step 4: Update `ApartmentPurchaseModule.kt`** + +Remove shared use cases and shared ViewModels (they're now in `purchaseCommonModule`). Keep only apartment-specific ones: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.di + +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.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } +} +``` + +- [ ] **Step 5: Update `PurchaseApartmentModels.kt`** + +Remove `SigningStart`, `SigningPollResult`, `SigningStatus` (now in common). Keep only apartment-specific models: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +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, +) +``` + +- [ ] **Step 6: Delete shared files from apartment module** + +Delete these files/directories that moved to common: +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt` +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql` + +- [ ] **Step 7: Verify apartment build compiles** + +Run: `./gradlew :app:feature:feature-purchase-apartment:compileDebugKotlin :app:feature:feature-purchase-common:compileDebugKotlin` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 8: Commit** + +```bash +git add -A app/feature/feature-purchase-apartment/ app/feature/feature-purchase-common/ +git commit -m "refactor: migrate apartment purchase to use feature-purchase-common" +``` + +--- + +### Task 5: Create `feature-purchase-car` module + +**Files:** +- Create: `app/feature/feature-purchase-car/build.gradle.kts` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt` + +- [ ] **Step 1: Create `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.featurePurchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} +``` + +- [ ] **Step 2: Create GraphQL operations** + +File: `app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql` +```graphql +mutation CarShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql` +```graphql +mutation CarPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql` +```graphql +mutation CarPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql` +```graphql +mutation CarPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...PurchaseProductOfferFragment + } + } + userError { + message + } + } +} +``` + +Note: `PurchaseProductOfferFragment` is defined in `feature-purchase-common`. The car module depends on common, so Apollo will find this fragment via the dependency. + +- [ ] **Step 3: Create data models** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class CarOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class CarTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) +``` + +- [ ] **Step 4: Create `CreateCarSessionAndPriceIntentUseCase`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.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.CarPriceIntentCreateMutation +import octopus.CarShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateCarSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateCarSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateCarSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(CarShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(CarPriceIntentCreateMutation(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 5: Create `SubmitCarFormAndGetOffersUseCase`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.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.CarPriceIntentConfirmMutation +import octopus.CarPriceIntentDataUpdateMutation +import octopus.fragment.PurchaseProductOfferFragment + +internal interface SubmitCarFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either +} + +internal class SubmitCarFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitCarFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("registrationNumber", registrationNumber) + put("mileage", mileage) + put("street", street) + put("zipCode", zipCode) + put("email", email) + } + + val updateResult = apolloClient + .mutation(CarPriceIntentDataUpdateMutation(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(CarPriceIntentConfirmMutation(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()) + } + + CarOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +private fun PurchaseProductOfferFragment.toTierOffer(): CarTierOffer { + return CarTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} +``` + +- [ ] **Step 6: Create `CarFormViewModel`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.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.car.data.CarOffers +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.SessionAndIntent +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class CarFormViewModel( + productName: String, + createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = CarFormState(), + presenter = CarFormPresenter(productName, createCarSessionAndPriceIntentUseCase, submitCarFormAndGetOffersUseCase), +) + +internal sealed interface CarFormEvent { + data class SubmitForm( + val ssn: String, + val registrationNumber: String, + val mileage: Int?, + val street: String, + val zipCode: String, + val email: String, + ) : CarFormEvent + + data object ClearNavigation : CarFormEvent + data object Retry : CarFormEvent +} + +internal data class CarFormState( + val ssnError: String? = null, + val registrationNumberError: String? = null, + val mileageError: String? = null, + val streetError: String? = null, + val zipCodeError: String? = null, + val emailError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: CarOffersNavigationData? = null, +) + +internal data class CarOffersNavigationData( + val shopSessionId: String, + val offers: CarOffers, +) + +private class CarFormPresenter( + private val productName: String, + private val createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + private val submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: CarFormState): CarFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: CarFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is CarFormEvent.SubmitForm -> { + val errors = validate(event) + if (errors.hasErrors()) { + currentState = currentState.copy( + ssnError = errors.ssnError, + registrationNumberError = errors.registrationNumberError, + mileageError = errors.mileageError, + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + emailError = errors.emailError, + ) + } else { + currentState = currentState.copy( + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + CarFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + CarFormEvent.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) + createCarSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitCarFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = submit.ssn, + registrationNumber = submit.registrationNumber.replace(" ", ""), + mileage = submit.mileage!!, + street = submit.street, + zipCode = submit.zipCode, + email = submit.email, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "N\u00e5got gick fel", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = CarOffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val ssnError: String?, + val registrationNumberError: String?, + val mileageError: String?, + val streetError: String?, + val zipCodeError: String?, + val emailError: String?, +) { + fun hasErrors(): Boolean = ssnError != null || registrationNumberError != null || + mileageError != null || streetError != null || zipCodeError != null || emailError != null +} + +private val SSN_REGEX = Regex("^\\d{12}$") +private val REG_NUMBER_REGEX = Regex("^[A-Za-z]{3}\\s?\\d{2}[A-Za-z0-9]$") +private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + +private fun validate(form: CarFormEvent.SubmitForm): ValidationErrors { + return ValidationErrors( + ssnError = when { + form.ssn.isBlank() -> "Ange personnummer" + !SSN_REGEX.matches(form.ssn) -> "Ange ett giltigt personnummer (12 siffror)" + else -> null + }, + registrationNumberError = when { + form.registrationNumber.isBlank() -> "Ange registreringsnummer" + !REG_NUMBER_REGEX.matches(form.registrationNumber.replace(" ", "").let { + if (it.length >= 3) it.substring(0, 3) + " " + it.substring(3) else it + }) -> "Ange ett giltigt registreringsnummer (t.ex. ABC 123)" + else -> null + }, + mileageError = if (form.mileage == null) "V\u00e4lj miltal" else null, + streetError = if (form.street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + form.zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !form.zipCode.all { it.isDigit() } -> "Postnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + emailError = when { + form.email.isBlank() -> "Ange e-postadress" + !EMAIL_REGEX.matches(form.email) -> "Ange en giltig e-postadress" + else -> null + }, + ) +} +``` + +- [ ] **Step 7: Create `CarFormDestination`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.HedvigDropdownField +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.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.car.data.CarOffers + +@Composable +internal fun CarFormDestination( + viewModel: CarFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: CarOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(CarFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Bilf\u00f6rs\u00e4kring", + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + + else -> { + var ssn by remember { mutableStateOf("") } + var registrationNumber by remember { mutableStateOf("") } + var selectedMileage: MileageOption? by remember { mutableStateOf(null) } + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + + CarFormContent( + ssn = ssn, + registrationNumber = registrationNumber, + selectedMileage = selectedMileage, + street = street, + zipCode = zipCode, + email = email, + ssnError = uiState.ssnError, + registrationNumberError = uiState.registrationNumberError, + mileageError = uiState.mileageError, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + emailError = uiState.emailError, + isSubmitting = uiState.isSubmitting, + onSsnChanged = { value -> if (value.all { it.isDigit() } && value.length <= 12) ssn = value }, + onRegistrationNumberChanged = { value -> + val cleaned = value.uppercase().filter { it.isLetterOrDigit() } + registrationNumber = if (cleaned.length > 3) { + cleaned.substring(0, 3) + " " + cleaned.substring(3, minOf(cleaned.length, 6)) + } else { + cleaned + } + }, + onMileageSelected = { selectedMileage = it }, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onEmailChanged = { email = it }, + onSubmit = { + viewModel.emit( + CarFormEvent.SubmitForm( + ssn = ssn, + registrationNumber = registrationNumber, + mileage = selectedMileage?.value, + street = street, + zipCode = zipCode, + email = email, + ), + ) + }, + onRetry = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + } + } +} + +internal enum class MileageOption(val value: Int, val label: String) { + M_1000(1000, "0 - 1 000 mil"), + M_1500(1500, "1 000 - 1 500 mil"), + M_2000(2000, "1 500 - 2 000 mil"), + M_2500(2500, "2 000 - 2 500 mil"), + M_2501(2501, "2 500+ mil"), +} + +@Composable +private fun CarFormContent( + ssn: String, + registrationNumber: String, + selectedMileage: MileageOption?, + street: String, + zipCode: String, + email: String, + ssnError: String?, + registrationNumberError: String?, + mileageError: String?, + streetError: String?, + zipCodeError: String?, + emailError: String?, + isSubmitting: Boolean, + onSsnChanged: (String) -> Unit, + onRegistrationNumberChanged: (String) -> Unit, + onMileageSelected: (MileageOption) -> Unit, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onSubmit: () -> Unit, + onRetry: () -> Unit, +) { + Column( + 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(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HedvigTextField( + text = ssn, + onValueChange = onSsnChanged, + labelText = "Personnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = ssnError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = registrationNumber, + onValueChange = onRegistrationNumberChanged, + labelText = "Registreringsnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = registrationNumberError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + var mileageExpanded by remember { mutableStateOf(false) } + HedvigDropdownField( + text = selectedMileage?.label ?: "", + labelText = "Miltal per \u00e5r", + onItemChosen = { index -> + onMileageSelected(MileageOption.entries[index]) + mileageExpanded = false + }, + items = MileageOption.entries.map { it.label }, + expanded = mileageExpanded, + onExpandedChange = { mileageExpanded = it }, + errorState = mileageError.toErrorState(), + enabled = !isSubmitting, + ) + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = email, + onValueChange = onEmailChanged, + labelText = "E-post", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = emailError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + enabled = !isSubmitting, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Ber\u00e4kna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} +``` + +**Note:** `HedvigDropdownField` may not exist in the design system. If it doesn't, we'll need to check what dropdown component is available. The implementation agent should verify this and use whatever dropdown/exposed-dropdown-menu component the design system provides. If none exists, use a simple clickable text field that opens a bottom sheet or menu with the mileage options. + +- [ ] **Step 8: Create navigation destinations** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class CarPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface CarPurchaseDestination { + @Serializable + data object Form : CarPurchaseDestination, Destination +} +``` + +- [ ] **Step 9: Create navigation graph** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.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.car.ui.form.CarFormDestination +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.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.carPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = CarPurchaseDestination.Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: CarFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + CarFormDestination( + 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() + }, + ) + } + } + // NOTE: Success destination is registered once in HedvigNavHost, not here. + // Both apartment and car nav graphs navigate to the same PurchaseCommonDestination.Success. +} +``` + +- [ ] **Step 10: Create DI module** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.di + +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val carPurchaseModule = module { + single { CreateCarSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitCarFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + CarFormViewModel( + productName = params.get(), + createCarSessionAndPriceIntentUseCase = get(), + submitCarFormAndGetOffersUseCase = get(), + ) + } +} +``` + +- [ ] **Step 11: Commit** + +```bash +git add app/feature/feature-purchase-car/ +git commit -m "feat: add feature-purchase-car module with form, use cases, and navigation" +``` + +--- + +### Task 6: Wire car purchase into app navigation and DI + +**Files:** +- Modify: `app/app/build.gradle.kts` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` +- Modify: `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt` +- Modify: `app/feature/feature-insurances/build.gradle.kts` (if needed for nav dependency) + +- [ ] **Step 1: Add dependencies to app build.gradle.kts** + +Add to `app/app/build.gradle.kts` dependencies: +```kotlin +implementation(projects.featurePurchaseCommon) +implementation(projects.featurePurchaseCar) +``` + +- [ ] **Step 2: Register modules in ApplicationModule** + +In `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt`, add imports and include the new modules: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.common.di.purchaseCommonModule +import com.hedvig.android.feature.purchase.car.di.carPurchaseModule +``` + +Add to the `includes(...)` block (near `apartmentPurchaseModule`): +```kotlin +purchaseCommonModule, +carPurchaseModule, +``` + +- [ ] **Step 3: Add car purchase nav graph to HedvigNavHost** + +In `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt`: + +Add imports: +```kotlin +import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseGraphDestination +import com.hedvig.android.feature.purchase.car.navigation.carPurchaseNavGraph +``` + +Add the nav graph call after `apartmentPurchaseNavGraph(...)`: +```kotlin +carPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, +) +``` + +Also add the shared `PurchaseCommonDestination.Success` destination registration (once, shared by both purchase flows): +```kotlin +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination + +// In the NavHost builder, after both purchase nav graphs: +navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.popBackStack()) finishApp() + }, + ) +} +``` + +Add car purchase navigation callback in the insurances graph setup. Find `onNavigateToApartmentPurchase` and add a new parameter: +```kotlin +onNavigateToCarPurchase = { productName -> + navController.navigate(CarPurchaseGraphDestination(productName)) +}, +``` + +- [ ] **Step 4: Update InsuranceGraph to route car cross-sells** + +In `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt`: + +Add `onNavigateToCarPurchase: (productName: String) -> Unit` parameter to `insuranceGraph()`. + +Update the `onCrossSellClick` callback (currently hardcoded at line 64-67): + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> + // TODO: Extract product name from cross-sell data and route accordingly + // For now, route apartment cross-sells to apartment and car cross-sells to car + onNavigateToApartmentPurchase("SE_APARTMENT_RENT") +}, +``` + +Note: The proper product routing (determining if a cross-sell is for apartment vs car based on the URL or cross-sell metadata) depends on how cross-sell data is structured. The implementing agent should check what data `onCrossSellClick` receives and route to the correct flow. For now, the car flow is wired up and can be tested directly via `CarPurchaseGraphDestination("SE_CAR")`. + +- [ ] **Step 5: Verify full build compiles** + +Run: `./gradlew :app:app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 6: Run ktlint formatting** + +Run: `./gradlew ktlintFormat` + +- [ ] **Step 7: Commit** + +```bash +git add app/app/ app/feature/feature-insurances/ +git commit -m "feat: wire car purchase flow into app navigation and DI" +``` + +--- + +### Task 7: Verify everything works end-to-end + +- [ ] **Step 1: Run full project build** + +Run: `./gradlew :app:app:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: Run tests for affected modules** + +Run: `./gradlew :app:feature:feature-purchase-common:test :app:feature:feature-purchase-apartment:test :app:feature:feature-purchase-car:test` +Expected: All tests pass (or no tests to run for new modules) + +- [ ] **Step 3: Run ktlint check** + +Run: `./gradlew ktlintCheck` +Expected: No violations + +- [ ] **Step 4: Commit any fixes** + +If any build or lint issues found, fix and commit. From b0b921e880db6e9fd76656021d88bb7b0e3a7461 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 18:48:47 +0200 Subject: [PATCH 03/14] feat: create feature-purchase-common module with shared screens, models, and GraphQL Co-Authored-By: Claude Opus 4.6 (1M context) --- .../feature-purchase-common/build.gradle.kts | 51 +++ .../PurchaseCartEntriesAddMutation.graphql | 10 + .../PurchaseProductOfferFragment.graphql | 50 +++ .../PurchaseShopSessionSigningQuery.graphql | 17 + .../graphql/PurchaseStartSignMutation.graphql | 19 ++ .../data/AddToCartAndStartSignUseCase.kt | 68 ++++ .../common/data/PollSigningStatusUseCase.kt | 53 ++++ .../common/data/PurchaseCommonModels.kt | 17 + .../common/di/PurchaseCommonModule.kt | 32 ++ .../navigation/PurchaseCommonDestination.kt | 80 +++++ .../ui/failure/PurchaseFailureDestination.kt | 30 ++ .../common/ui/offer/SelectTierDestination.kt | 290 ++++++++++++++++++ .../common/ui/offer/SelectTierViewModel.kt | 142 +++++++++ .../common/ui/sign/SigningDestination.kt | 194 ++++++++++++ .../common/ui/sign/SigningViewModel.kt | 107 +++++++ .../ui/success/PurchaseSuccessDestination.kt | 61 ++++ .../ui/summary/PurchaseSummaryDestination.kt | 184 +++++++++++ .../ui/summary/PurchaseSummaryViewModel.kt | 102 ++++++ 18 files changed, 1507 insertions(+) create mode 100644 app/feature/feature-purchase-common/build.gradle.kts create mode 100644 app/feature/feature-purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql create mode 100644 app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql create mode 100644 app/feature/feature-purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql create mode 100644 app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt create mode 100644 app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt diff --git a/app/feature/feature-purchase-common/build.gradle.kts b/app/feature/feature-purchase-common/build.gradle.kts new file mode 100644 index 0000000000..9dc73af15f --- /dev/null +++ b/app/feature/feature-purchase-common/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.apollo.normalizedCache) + 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(libs.zXing) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.designSystemHedvig) + 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-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql b/app/feature/feature-purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql new file mode 100644 index 0000000000..fd83e5fe24 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql @@ -0,0 +1,10 @@ +mutation PurchaseCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql b/app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql new file mode 100644 index 0000000000..fb261ffc87 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql @@ -0,0 +1,50 @@ +fragment PurchaseProductOfferFragment 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-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql b/app/feature/feature-purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql new file mode 100644 index 0000000000..e89bb0ec25 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql @@ -0,0 +1,17 @@ +query PurchaseShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql b/app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql new file mode 100644 index 0000000000..e4bf5c63f3 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql @@ -0,0 +1,19 @@ +mutation PurchaseStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt new file mode 100644 index 0000000000..e4eb051a8c --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.feature.purchase.common.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.PurchaseCartEntriesAddMutation +import octopus.PurchaseStartSignMutation + +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(PurchaseCartEntriesAddMutation(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(PurchaseStartSignMutation(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-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt new file mode 100644 index 0000000000..1d860525df --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt @@ -0,0 +1,53 @@ +package com.hedvig.android.feature.purchase.common.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +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 +import com.hedvig.android.logger.logcat +import octopus.PurchaseShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +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(PurchaseShopSessionSigningQuery(signingId = signingId)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + val signing = result.shopSessionSigning + val status = when (signing.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__, + -> SigningStatus.PENDING + } + SigningPollResult( + status = status, + liveQrCodeData = signing.seBankidProperties?.liveQrCodeData, + ) + }, + ) + } + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt new file mode 100644 index 0000000000..1283e9372a --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt @@ -0,0 +1,17 @@ +package com.hedvig.android.feature.purchase.common.data + +data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +data class SigningPollResult( + val status: SigningStatus, + val liveQrCodeData: String?, +) + +enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt new file mode 100644 index 0000000000..a02a597966 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.purchase.common.di + +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val purchaseCommonModule = module { + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = 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-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt new file mode 100644 index 0000000000..4fabf7a60e --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt @@ -0,0 +1,80 @@ +package com.hedvig.android.feature.purchase.common.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 + +sealed interface PurchaseCommonDestination { + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : PurchaseCommonDestination, Destination + + @Serializable + data object Failure : PurchaseCommonDestination, Destination +} + +@Serializable +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 +data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt new file mode 100644 index 0000000000..1c90f96b7b --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt @@ -0,0 +1,30 @@ +package com.hedvig.android.feature.purchase.common.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 +fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + ) { + HedvigErrorSection( + onButtonClick = onRetry, + modifier = Modifier.weight(1f), + ) + } +} + +@HedvigPreview +@Composable +private fun PreviewPurchaseFailure() { + HedvigTheme { + PurchaseFailureDestination(onRetry = {}, close = {}) + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt new file mode 100644 index 0000000000..d008fceadf --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt @@ -0,0 +1,290 @@ +package com.hedvig.android.feature.purchase.common.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.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.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.common.navigation.SummaryParameters +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +@Composable +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) + } + } + SelectTierContent( + uiState = uiState, + navigateUp = navigateUp, + onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, + onSelectDeductible = { tierName, offerId -> + viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) + }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierContent( + uiState: SelectTierUiState, + navigateUp: () -> Unit = {}, + onSelectTier: (String) -> Unit = {}, + onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, + onContinue: () -> Unit = {}, +) { + HedvigScaffold( + navigateUp = 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, 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.tierGroups.lastIndex) { + Spacer(Modifier.height(12.dp)) + } + } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { onContinue() }, + enabled = uiState.selectedTierName.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun TierGroupCard( + tierGroup: TierGroup, + isSelected: Boolean, + selectedDeductibleId: String, + onSelectTier: () -> Unit, + onSelectDeductible: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } + HedvigCard( + onClick = onSelectTier, + borderColor = if (isSelected) { + HedvigTheme.colorScheme.signalGreenElement + } else { + HedvigTheme.colorScheme.borderSecondary + }, + modifier = modifier, + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = tierGroup.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + if (selectedOption != null) { + HedvigText( + text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), + style = HedvigTheme.typography.bodyLarge, + ) + } + } + AnimatedVisibility( + visible = isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + if (tierGroup.usps.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + for (usp in tierGroup.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, + ) + } + } + } + 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( + visible = !isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "V\u00e4lj ${tierGroup.tierDisplayName}", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + } +} + +private fun formatPrice(amount: Double, currencyCode: String): String { + @Suppress("DEPRECATION") + val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) + format.currency = Currency.getInstance(currencyCode) + format.maximumFractionDigits = 0 + return "${format.format(amount)}/m\u00e5n" +} + +private val previewTierGroups = listOf( + TierGroup( + tierDisplayName = "Hem Max", + 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), + ), + ), + TierGroup( + tierDisplayName = "Hem Standard", + 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), + ), + ), + TierGroup( + tierDisplayName = "Hem Bas", + tierDescription = "Inneh\u00e5ller v\u00e5rt grundskydd", + usps = listOf("Grundskydd"), + 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), + ), + ), +) + +@HedvigPreview +@Composable +private fun PreviewSelectTierStandard() { + HedvigTheme { + SelectTierContent( + uiState = SelectTierUiState( + tierGroups = previewTierGroups, + selectedTierName = "Hem Standard", + selectedDeductibleByTier = mapOf( + "Hem Max" to "1c", + "Hem Standard" to "2a", + "Hem Bas" to "3c", + ), + shopSessionId = "session", + productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", + summaryToNavigate = null, + ), + ) + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt new file mode 100644 index 0000000000..67b39d4a45 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt @@ -0,0 +1,142 @@ +package com.hedvig.android.feature.purchase.common.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.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class SelectTierViewModel( + params: SelectTierParameters, +) : MoleculeViewModel( + 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 }, + ) + } +} + +class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { + 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.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, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } + } + } + + return SelectTierUiState( + tierGroups = lastState.tierGroups, + selectedTierName = selectedTierName, + selectedDeductibleByTier = selectedDeductibleByTier, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +data class TierGroup( + val tierDisplayName: String, + val tierDescription: String, + val usps: List, + val deductibleOptions: List, +) + +data class DeductibleOption( + val offerId: String, + val deductibleDisplayName: String, + val netAmount: Double, + val netCurrencyCode: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val hasDiscount: Boolean, +) + +data class SelectTierUiState( + val tierGroups: List, + val selectedTierName: String, + val selectedDeductibleByTier: Map, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters?, +) + +sealed interface SelectTierEvent { + data class SelectTier(val tierName: String) : SelectTierEvent + + data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent + + data object Continue : SelectTierEvent + + data object ClearNavigation : SelectTierEvent +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt new file mode 100644 index 0000000000..ab048a2dda --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt @@ -0,0 +1,194 @@ +package com.hedvig.android.feature.purchase.common.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 +fun SigningDestination( + viewModel: SigningViewModel, + navigateToSuccess: (startDate: String?) -> Unit, + navigateToFailure: () -> Unit, +) { + 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 -> { + 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, + is SigningUiState.Failed, + -> HedvigFullScreenCenterAlignedProgress() + } +} + +@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\u00e5 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 = "\u00d6ppna BankID", + onClick = onOpenBankId, + enabled = true, + 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-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt new file mode 100644 index 0000000000..5d144e637f --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt @@ -0,0 +1,107 @@ +package com.hedvig.android.feature.purchase.common.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.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.SigningStatus +import com.hedvig.android.feature.purchase.common.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 + +class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = null, + bankIdOpened = false, + ), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), + ) + +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 -> {} + } + } + + LaunchedEffect(Unit) { + while (true) { + pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( + ifLeft = { + currentState = SigningUiState.Failed + return@LaunchedEffect + }, + ifRight = { pollResult -> + when (pollResult.status) { + SigningStatus.SIGNED -> { + currentState = SigningUiState.Success(startDate = signingParameters.startDate) + return@LaunchedEffect + } + + SigningStatus.FAILED -> { + currentState = SigningUiState.Failed + return@LaunchedEffect + } + + SigningStatus.PENDING -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = pollResult.liveQrCodeData, + bankIdOpened = bankIdOpened, + ) + } + } + }, + ) + delay(2_000) + } + } + + return when (val state = currentState) { + is SigningUiState.Polling -> state.copy(bankIdOpened = bankIdOpened) + else -> currentState + } + } +} + +sealed interface SigningUiState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val liveQrCodeData: String?, + val bankIdOpened: Boolean, + ) : SigningUiState + + data class Success(val startDate: String?) : SigningUiState + + data object Failed : SigningUiState +} + +sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + + data object ClearNavigation : SigningEvent +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt new file mode 100644 index 0000000000..6416997d25 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt @@ -0,0 +1,61 @@ +package com.hedvig.android.feature.purchase.common.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.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.TopAppBarActionType + +@Composable +fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + itemsColumnHorizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + HedvigText( + text = "Din f\u00f6rs\u00e4kring \u00e4r 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\u00e4ng", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = true, + onClick = close, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@HedvigPreview +@Composable +private fun PreviewPurchaseSuccess() { + HedvigTheme { + PurchaseSuccessDestination(startDate = "2026-05-01", close = {}) + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt new file mode 100644 index 0000000000..12624e3747 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt @@ -0,0 +1,184 @@ +package com.hedvig.android.feature.purchase.common.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.Surface +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData + +@Composable +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, + ) + 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 = { + HedvigText( + text = "Pris", + style = HedvigTheme.typography.bodySmall, + ) + }, + spaceBetween = 8.dp, + endSlot = { + 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, + ) + } + }, + ) + } + } + 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)) + } +} + +@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\u00e5r mest popul\u00e4ra f\u00f6rs\u00e4kring", + grossAmount = 139.0, + grossCurrencyCode = "SEK", + netAmount = 118.0, + netCurrencyCode = "SEK", + usps = emptyList(), + exposureDisplayName = "Storgatan 1", + deductibleDisplayName = "1 500 kr", + hasDiscount = true, + ), + productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", + ), + isSubmitting = false, + navigateUp = {}, + onConfirm = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt new file mode 100644 index 0000000000..324c5adb34 --- /dev/null +++ b/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt @@ -0,0 +1,102 @@ +package com.hedvig.android.feature.purchase.common.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.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = false, + signingToNavigate = null, + navigateToFailure = false, + ), + presenter = PurchaseSummaryPresenter( + summaryParameters, + addToCartAndStartSignUseCase, + ), + ) + +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, + ) + } +} + +data class PurchaseSummaryUiState( + val params: SummaryParameters, + val isSubmitting: Boolean, + val signingToNavigate: SigningParameters?, + val navigateToFailure: Boolean, +) + +sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + + data object ClearNavigation : PurchaseSummaryEvent +} From 8ca9073dbbe25083860c8eb0dc96deaba70d98a0 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 18:52:34 +0200 Subject: [PATCH 04/14] refactor: migrate apartment purchase to use feature-purchase-common Co-Authored-By: Claude Opus 4.6 (1M context) --- .../build.gradle.kts | 2 +- .../main/graphql/ProductOfferFragment.graphql | 50 --- .../ShopSessionCartEntriesAddMutation.graphql | 10 - .../graphql/ShopSessionSigningQuery.graphql | 17 - .../ShopSessionStartSignMutation.graphql | 19 -- .../data/AddToCartAndStartSignUseCase.kt | 68 ---- .../data/PollSigningStatusUseCase.kt | 53 ---- .../apartment/data/PurchaseApartmentModels.kt | 16 - .../apartment/di/ApartmentPurchaseModule.kt | 24 -- .../ApartmentPurchaseDestination.kt | 74 ----- .../navigation/ApartmentPurchaseNavGraph.kt | 39 +-- .../ui/failure/PurchaseFailureDestination.kt | 30 -- .../ui/offer/SelectTierDestination.kt | 290 ------------------ .../apartment/ui/offer/SelectTierViewModel.kt | 142 --------- .../apartment/ui/sign/SigningDestination.kt | 194 ------------ .../apartment/ui/sign/SigningViewModel.kt | 107 ------- .../ui/success/PurchaseSuccessDestination.kt | 61 ---- .../ui/summary/PurchaseSummaryDestination.kt | 184 ----------- .../ui/summary/PurchaseSummaryViewModel.kt | 102 ------ 19 files changed, 17 insertions(+), 1465 deletions(-) delete mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql delete mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql delete mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql delete mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt delete mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt delete 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/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index 58daa4a1f0..95ac9eed56 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -24,7 +24,6 @@ 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) @@ -33,6 +32,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.dataCrossSellAfterFlow) implementation(projects.designSystemHedvig) + implementation(projects.featurePurchaseCommon) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCommon) diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql deleted file mode 100644 index d436604c50..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 4591859668..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -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/ShopSessionSigningQuery.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql deleted file mode 100644 index bc326afc0f..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0067b7708d..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql +++ /dev/null @@ -1,19 +0,0 @@ -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/data/AddToCartAndStartSignUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt deleted file mode 100644 index ef7705d31e..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt +++ /dev/null @@ -1,68 +0,0 @@ -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/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt deleted file mode 100644 index 0cf4719af1..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ /dev/null @@ -1,53 +0,0 @@ -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.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 -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)) - .fetchPolicy(FetchPolicy.NetworkOnly) - .safeExecute() - .fold( - ifLeft = { - logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } - raise(ErrorMessage()) - }, - ifRight = { result -> - val signing = result.shopSessionSigning - val status = when (signing.status) { - ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED - - ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED - - ShopSessionSigningStatus.PENDING, - ShopSessionSigningStatus.CREATING, - 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 cd67040a71..8866ad42db 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 @@ -23,19 +23,3 @@ internal data class ApartmentTierOffer( val deductibleDisplayName: String?, val hasDiscount: Boolean, ) - -internal data class SigningStart( - val signingId: String, - val autoStartToken: String, -) - -internal data class SigningPollResult( - val status: SigningStatus, - val liveQrCodeData: 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/di/ApartmentPurchaseModule.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt index 8255554230..06563a35f9 100644 --- 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 @@ -1,25 +1,16 @@ 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( @@ -28,19 +19,4 @@ val apartmentPurchaseModule = module { 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/ApartmentPurchaseDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt index c03968519d..fb2867cbf2 100644 --- 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 @@ -1,9 +1,6 @@ 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 @@ -14,75 +11,4 @@ data class ApartmentPurchaseGraphDestination( 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?, -) 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 11f8293c71..eaab285478 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 @@ -6,22 +6,25 @@ 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.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.navgraph import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack @@ -129,14 +132,4 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( ) } } - - navdestination { backStackEntry -> - val route = backStackEntry.toRoute() - PurchaseSuccessDestination( - startDate = route.startDate, - close = dropUnlessResumed { - if (!navController.popBackStack()) finishApp() - }, - ) - } } 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 deleted file mode 100644 index a09ae6b6a2..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt +++ /dev/null @@ -1,30 +0,0 @@ -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 -internal fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { - HedvigScaffold( - navigateUp = close, - topAppBarActionType = TopAppBarActionType.CLOSE, - ) { - HedvigErrorSection( - onButtonClick = onRetry, - modifier = Modifier.weight(1f), - ) - } -} - -@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/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt deleted file mode 100644 index 894904d634..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ /dev/null @@ -1,290 +0,0 @@ -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -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.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.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 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) - } - } - SelectTierContent( - uiState = uiState, - navigateUp = navigateUp, - onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, - onSelectDeductible = { tierName, offerId -> - viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) - }, - onContinue = { viewModel.emit(SelectTierEvent.Continue) }, - ) -} - -@Composable -private fun SelectTierContent( - uiState: SelectTierUiState, - navigateUp: () -> Unit = {}, - onSelectTier: (String) -> Unit = {}, - onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, - onContinue: () -> Unit = {}, -) { - HedvigScaffold( - navigateUp = 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, 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.tierGroups.lastIndex) { - Spacer(Modifier.height(12.dp)) - } - } - Spacer(Modifier.height(24.dp)) - HedvigButton( - text = "Forts\u00e4tt", - onClick = dropUnlessResumed { onContinue() }, - enabled = uiState.selectedTierName.isNotEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - Spacer(Modifier.height(16.dp)) - } -} - -@Composable -private fun TierGroupCard( - tierGroup: TierGroup, - isSelected: Boolean, - selectedDeductibleId: String, - onSelectTier: () -> Unit, - onSelectDeductible: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } - HedvigCard( - onClick = onSelectTier, - borderColor = if (isSelected) { - HedvigTheme.colorScheme.signalGreenElement - } else { - HedvigTheme.colorScheme.borderSecondary - }, - modifier = modifier, - ) { - Column(Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - HedvigText( - text = tierGroup.tierDisplayName, - style = HedvigTheme.typography.bodyLarge, - ) - if (selectedOption != null) { - HedvigText( - text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), - style = HedvigTheme.typography.bodyLarge, - ) - } - } - AnimatedVisibility( - visible = isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - if (tierGroup.usps.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - for (usp in tierGroup.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, - ) - } - } - } - 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( - visible = !isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - Spacer(Modifier.height(8.dp)) - HedvigText( - text = "V\u00e4lj ${tierGroup.tierDisplayName}", - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, - ) - } - } - } - } -} - -private fun formatPrice(amount: Double, currencyCode: String): String { - @Suppress("DEPRECATION") - val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) - format.currency = Currency.getInstance(currencyCode) - format.maximumFractionDigits = 0 - return "${format.format(amount)}/m\u00e5n" -} - -private val previewTierGroups = listOf( - TierGroup( - tierDisplayName = "Hem Max", - 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), - ), - ), - TierGroup( - tierDisplayName = "Hem Standard", - 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), - ), - ), - TierGroup( - tierDisplayName = "Hem Bas", - tierDescription = "Inneh\u00e5ller v\u00e5rt grundskydd", - usps = listOf("Grundskydd"), - 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), - ), - ), -) - -@HedvigPreview -@Composable -private fun PreviewSelectTierStandard() { - HedvigTheme { - SelectTierContent( - uiState = SelectTierUiState( - tierGroups = previewTierGroups, - selectedTierName = "Hem Standard", - selectedDeductibleByTier = mapOf( - "Hem Max" to "1c", - "Hem Standard" to "2a", - "Hem Bas" to "3c", - ), - shopSessionId = "session", - productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", - summaryToNavigate = null, - ), - ) - } -} 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 deleted file mode 100644 index bebd2f7edf..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt +++ /dev/null @@ -1,142 +0,0 @@ -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 -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( - 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 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.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, - selectedOffer = selectedOffer, - productDisplayName = params.productDisplayName, - ) - } - - SelectTierEvent.ClearNavigation -> { - summaryToNavigate = null - } - } - } - - return SelectTierUiState( - tierGroups = lastState.tierGroups, - selectedTierName = selectedTierName, - selectedDeductibleByTier = selectedDeductibleByTier, - shopSessionId = params.shopSessionId, - productDisplayName = params.productDisplayName, - summaryToNavigate = summaryToNavigate, - ) - } -} - -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 tierGroups: List, - val selectedTierName: String, - val selectedDeductibleByTier: Map, - val shopSessionId: String, - val productDisplayName: String, - val summaryToNavigate: SummaryParameters?, -) - -internal sealed interface SelectTierEvent { - data class SelectTier(val tierName: String) : SelectTierEvent - - data class SelectDeductible(val tierName: String, 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 deleted file mode 100644 index 490eff39a7..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ /dev/null @@ -1,194 +0,0 @@ -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.lifecycle.compose.collectAsStateWithLifecycle -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 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( - viewModel: SigningViewModel, - navigateToSuccess: (startDate: String?) -> Unit, - navigateToFailure: () -> Unit, -) { - 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 -> { - 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, - is SigningUiState.Failed, - -> HedvigFullScreenCenterAlignedProgress() - } -} - -@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 = "\u00d6ppna BankID", - onClick = onOpenBankId, - enabled = true, - 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 deleted file mode 100644 index 49e98d1094..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -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, - liveQrCodeData = null, - 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 -> {} - } - } - - LaunchedEffect(Unit) { - while (true) { - pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( - ifLeft = { - currentState = SigningUiState.Failed - return@LaunchedEffect - }, - ifRight = { pollResult -> - when (pollResult.status) { - SigningStatus.SIGNED -> { - currentState = SigningUiState.Success(startDate = signingParameters.startDate) - return@LaunchedEffect - } - - SigningStatus.FAILED -> { - currentState = SigningUiState.Failed - return@LaunchedEffect - } - - SigningStatus.PENDING -> { - currentState = SigningUiState.Polling( - autoStartToken = signingParameters.autoStartToken, - startDate = signingParameters.startDate, - liveQrCodeData = pollResult.liveQrCodeData, - bankIdOpened = bankIdOpened, - ) - } - } - }, - ) - delay(2_000) - } - } - - 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 liveQrCodeData: 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 deleted file mode 100644 index 8edeb135ab..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt +++ /dev/null @@ -1,61 +0,0 @@ -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.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.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)) - } -} - -@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 deleted file mode 100644 index 8cc0899aac..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt +++ /dev/null @@ -1,184 +0,0 @@ -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.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( - 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, - ) - 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 = { - HedvigText( - text = "Pris", - style = HedvigTheme.typography.bodySmall, - ) - }, - spaceBetween = 8.dp, - endSlot = { - 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, - ) - } - }, - ) - } - } - 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)) - } -} - -@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 = {}, - ) - } - } -} 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 deleted file mode 100644 index 02348fb2af..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -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 4bbcec5ea3647e635f3b4a4a232a2afb8eb7f8c3 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 18:58:20 +0200 Subject: [PATCH 05/14] feat: add feature-purchase-car module with form, use cases, and navigation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../feature-purchase-car/build.gradle.kts | 40 +++ .../CarPriceIntentConfirmMutation.graphql | 13 + .../CarPriceIntentCreateMutation.graphql | 5 + .../CarPriceIntentDataUpdateMutation.graphql | 10 + .../CarShopSessionCreateMutation.graphql | 5 + .../purchase/car/data/CarPurchaseModels.kt | 25 ++ .../CreateCarSessionAndPriceIntentUseCase.kt | 48 +++ .../data/SubmitCarFormAndGetOffersUseCase.kt | 105 ++++++ .../purchase/car/di/CarPurchaseModule.kt | 22 ++ .../car/navigation/CarPurchaseDestination.kt | 14 + .../car/navigation/CarPurchaseNavGraph.kt | 134 +++++++ .../car/ui/form/CarFormDestination.kt | 339 ++++++++++++++++++ .../purchase/car/ui/form/CarFormViewModel.kt | 223 ++++++++++++ 13 files changed, 983 insertions(+) create mode 100644 app/feature/feature-purchase-car/build.gradle.kts create mode 100644 app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql create mode 100644 app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql create mode 100644 app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql create mode 100644 app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt create mode 100644 app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt diff --git a/app/feature/feature-purchase-car/build.gradle.kts b/app/feature/feature-purchase-car/build.gradle.kts new file mode 100644 index 0000000000..517945e139 --- /dev/null +++ b/app/feature/feature-purchase-car/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.featurePurchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql new file mode 100644 index 0000000000..b829370289 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql @@ -0,0 +1,13 @@ +mutation CarPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...PurchaseProductOfferFragment + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql new file mode 100644 index 0000000000..e7c85131bf --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation CarPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql new file mode 100644 index 0000000000..2cda442a51 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql @@ -0,0 +1,10 @@ +mutation CarPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql new file mode 100644 index 0000000000..1aa036ecda --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation CarShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt new file mode 100644 index 0000000000..6743776e31 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt @@ -0,0 +1,25 @@ +package com.hedvig.android.feature.purchase.car.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class CarOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class CarTierOffer( + 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, +) diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt new file mode 100644 index 0000000000..5ca31dab76 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt @@ -0,0 +1,48 @@ +package com.hedvig.android.feature.purchase.car.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.CarPriceIntentCreateMutation +import octopus.CarShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateCarSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateCarSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateCarSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(CarShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(CarPriceIntentCreateMutation(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-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt new file mode 100644 index 0000000000..b1cbef16f8 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt @@ -0,0 +1,105 @@ +package com.hedvig.android.feature.purchase.car.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.CarPriceIntentConfirmMutation +import octopus.CarPriceIntentDataUpdateMutation +import octopus.fragment.PurchaseProductOfferFragment + +internal interface SubmitCarFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either +} + +internal class SubmitCarFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitCarFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("registrationNumber", registrationNumber) + put("mileage", mileage) + put("street", street) + put("zipCode", zipCode) + put("email", email) + } + + val updateResult = apolloClient + .mutation(CarPriceIntentDataUpdateMutation(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(CarPriceIntentConfirmMutation(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()) + } + + CarOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +internal fun PurchaseProductOfferFragment.toTierOffer(): CarTierOffer { + return CarTierOffer( + 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-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt new file mode 100644 index 0000000000..a8d681d85b --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt @@ -0,0 +1,22 @@ +package com.hedvig.android.feature.purchase.car.di + +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val carPurchaseModule = module { + single { CreateCarSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitCarFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + CarFormViewModel( + productName = params.get(), + createCarSessionAndPriceIntentUseCase = get(), + submitCarFormAndGetOffersUseCase = get(), + ) + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt new file mode 100644 index 0000000000..8f202e4216 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.feature.purchase.car.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class CarPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface CarPurchaseDestination { + @Serializable + data object Form : CarPurchaseDestination, Destination +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt new file mode 100644 index 0000000000..0018e5ab26 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt @@ -0,0 +1,134 @@ +package com.hedvig.android.feature.purchase.car.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.car.navigation.CarPurchaseDestination.Form +import com.hedvig.android.feature.purchase.car.ui.form.CarFormDestination +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.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.carPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination
{ backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: CarFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + CarFormDestination( + 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() + }, + ) + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt new file mode 100644 index 0000000000..b5c933d727 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt @@ -0,0 +1,339 @@ +package com.hedvig.android.feature.purchase.car.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.MenuAnchorType +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 androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +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.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.car.data.CarOffers + +@Composable +internal fun CarFormDestination( + viewModel: CarFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: CarOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(CarFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Bilf\u00f6rs\u00e4kring", + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + + else -> { + var ssn by remember { mutableStateOf("") } + var registrationNumber by remember { mutableStateOf("") } + var selectedMileage: MileageOption? by remember { mutableStateOf(null) } + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + + CarFormContent( + ssn = ssn, + registrationNumber = registrationNumber, + selectedMileage = selectedMileage, + street = street, + zipCode = zipCode, + email = email, + ssnError = uiState.ssnError, + registrationNumberError = uiState.registrationNumberError, + mileageError = uiState.mileageError, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + emailError = uiState.emailError, + isSubmitting = uiState.isSubmitting, + onSsnChanged = { value -> if (value.length <= 12 && value.all { it.isDigit() }) ssn = value }, + onRegistrationNumberChanged = { value -> + registrationNumber = formatRegistrationNumber(value) + }, + onMileageSelected = { selectedMileage = it }, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onEmailChanged = { email = it }, + onSubmit = { + viewModel.emit( + CarFormEvent.SubmitForm( + ssn = ssn, + registrationNumber = registrationNumber, + mileage = selectedMileage?.value, + street = street, + zipCode = zipCode, + email = email, + ), + ) + }, + ) + } + } + } +} + +internal enum class MileageOption(val value: Int, val displayName: String) { + MILEAGE_0_1000(1000, "0 - 1 000 mil"), + MILEAGE_1000_1500(1500, "1 000 - 1 500 mil"), + MILEAGE_1500_2000(2000, "1 500 - 2 000 mil"), + MILEAGE_2000_2500(2500, "2 000 - 2 500 mil"), + MILEAGE_2500_PLUS(2501, "2 500+ mil"), +} + +private fun formatRegistrationNumber(input: String): String { + val cleaned = input.uppercase().filter { it.isLetterOrDigit() } + if (cleaned.length <= 3) return cleaned + return cleaned.take(3) + " " + cleaned.drop(3).take(3) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CarFormContent( + ssn: String, + registrationNumber: String, + selectedMileage: MileageOption?, + street: String, + zipCode: String, + email: String, + ssnError: String?, + registrationNumberError: String?, + mileageError: String?, + streetError: String?, + zipCodeError: String?, + emailError: String?, + isSubmitting: Boolean, + onSsnChanged: (String) -> Unit, + onRegistrationNumberChanged: (String) -> Unit, + onMileageSelected: (MileageOption) -> Unit, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onSubmit: () -> Unit, +) { + Column( + 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(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HedvigTextField( + text = ssn, + onValueChange = onSsnChanged, + labelText = "Personnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = ssnError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = registrationNumber, + onValueChange = onRegistrationNumberChanged, + labelText = "Registreringsnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = registrationNumberError.toErrorState(), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + var mileageExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = mileageExpanded, + onExpandedChange = { if (!isSubmitting) mileageExpanded = it }, + ) { + HedvigTextField( + text = selectedMileage?.displayName ?: "", + onValueChange = {}, + labelText = "Miltal", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = mileageError.toErrorState(), + readOnly = true, + enabled = !isSubmitting, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = mileageExpanded, + onDismissRequest = { mileageExpanded = false }, + ) { + MileageOption.entries.forEach { option -> + DropdownMenuItem( + text = { HedvigText(text = option.displayName) }, + onClick = { + onMileageSelected(option) + mileageExpanded = false + }, + ) + } + } + } + + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = email, + onValueChange = onEmailChanged, + labelText = "E-post", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = emailError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + enabled = !isSubmitting, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Ber\u00e4kna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} + +@HedvigPreview +@Composable +private fun PreviewCarFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + CarFormContent( + ssn = "", + registrationNumber = "", + selectedMileage = null, + street = "", + zipCode = "", + email = "", + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + isSubmitting = false, + onSsnChanged = {}, + onRegistrationNumberChanged = {}, + onMileageSelected = {}, + onStreetChanged = {}, + onZipCodeChanged = {}, + onEmailChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewCarFormFilled() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + CarFormContent( + ssn = "199001011234", + registrationNumber = "ABC 12D", + selectedMileage = MileageOption.MILEAGE_1000_1500, + street = "Storgatan 1", + zipCode = "12345", + email = "test@hedvig.com", + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + isSubmitting = false, + onSsnChanged = {}, + onRegistrationNumberChanged = {}, + onMileageSelected = {}, + onStreetChanged = {}, + onZipCodeChanged = {}, + onEmailChanged = {}, + onSubmit = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt new file mode 100644 index 0000000000..6d36c933a2 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt @@ -0,0 +1,223 @@ +package com.hedvig.android.feature.purchase.car.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.car.data.CarOffers +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.SessionAndIntent +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class CarFormViewModel( + productName: String, + createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = CarFormState(), + presenter = CarFormPresenter(productName, createCarSessionAndPriceIntentUseCase, submitCarFormAndGetOffersUseCase), + ) + +internal sealed interface CarFormEvent { + data class SubmitForm( + val ssn: String, + val registrationNumber: String, + val mileage: Int?, + val street: String, + val zipCode: String, + val email: String, + ) : CarFormEvent + + data object ClearNavigation : CarFormEvent + + data object Retry : CarFormEvent +} + +internal data class CarFormState( + val ssnError: String? = null, + val registrationNumberError: String? = null, + val mileageError: String? = null, + val streetError: String? = null, + val zipCodeError: String? = null, + val emailError: 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: CarOffers, +) + +private class CarFormPresenter( + private val productName: String, + private val createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + private val submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: CarFormState): CarFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: CarFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is CarFormEvent.SubmitForm -> { + val errors = validate( + event.ssn, + event.registrationNumber, + event.mileage, + event.street, + event.zipCode, + event.email, + ) + if (errors.hasErrors()) { + currentState = currentState.copy( + ssnError = errors.ssnError, + registrationNumberError = errors.registrationNumberError, + mileageError = errors.mileageError, + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + emailError = errors.emailError, + ) + } else { + currentState = currentState.copy( + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + CarFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + CarFormEvent.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) + createCarSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitCarFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = submit.ssn, + registrationNumber = submit.registrationNumber, + mileage = submit.mileage!!, + street = submit.street, + zipCode = submit.zipCode, + email = submit.email, + ).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 ssnError: String?, + val registrationNumberError: String?, + val mileageError: String?, + val streetError: String?, + val zipCodeError: String?, + val emailError: String?, +) { + fun hasErrors(): Boolean = ssnError != null || + registrationNumberError != null || + mileageError != null || + streetError != null || + zipCodeError != null || + emailError != null +} + +private val REGISTRATION_NUMBER_REGEX = Regex("^[A-Z]{3} \\d{2}[A-Z0-9]$") +private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + +private fun validate( + ssn: String, + registrationNumber: String, + mileage: Int?, + street: String, + zipCode: String, + email: String, +): ValidationErrors { + return ValidationErrors( + ssnError = when { + ssn.length != 12 -> "Ange ett giltigt personnummer (12 siffror)" + !ssn.all { it.isDigit() } -> "Personnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + registrationNumberError = when { + registrationNumber.isBlank() -> "Ange ett registreringsnummer" + !REGISTRATION_NUMBER_REGEX.matches(registrationNumber) -> "Ange ett giltigt registreringsnummer (t.ex. ABC 12D)" + else -> null + }, + mileageError = if (mileage == null) "V\u00e4lj miltal" else null, + streetError = if (street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + emailError = when { + email.isBlank() -> "Ange en e-postadress" + !EMAIL_REGEX.matches(email) -> "Ange en giltig e-postadress" + else -> null + }, + ) +} From 7626c707864b16dd55e7c95b4b555619ee75e98a Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 19:01:47 +0200 Subject: [PATCH 06/14] feat: wire car purchase flow into app navigation and DI Co-Authored-By: Claude Opus 4.6 (1M context) --- app/app/build.gradle.kts | 2 ++ .../android/app/di/ApplicationModule.kt | 6 +++- .../android/app/navigation/HedvigNavHost.kt | 33 ++++++++++++++++--- .../insurances/navigation/InsuranceGraph.kt | 1 + 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index c754732dc1..5ae965aeb8 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -184,6 +184,8 @@ dependencies { implementation(projects.designSystemInternals) implementation(projects.featureAddonPurchase) implementation(projects.featurePurchaseApartment) + implementation(projects.featurePurchaseCar) + implementation(projects.featurePurchaseCommon) 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 db0104ab6a..4ebc25854f 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,7 +63,6 @@ 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 @@ -80,6 +79,9 @@ import com.hedvig.android.feature.login.di.loginModule import com.hedvig.android.feature.movingflow.di.movingFlowModule import com.hedvig.android.feature.payments.di.paymentsModule import com.hedvig.android.feature.profile.di.profileModule +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule +import com.hedvig.android.feature.purchase.car.di.carPurchaseModule +import com.hedvig.android.feature.purchase.common.di.purchaseCommonModule import com.hedvig.android.feature.terminateinsurance.di.terminateInsuranceModule import com.hedvig.android.feature.travelcertificate.di.travelCertificateModule import com.hedvig.android.featureflags.di.featureManagerModule @@ -292,6 +294,7 @@ val applicationModule = module { addonPurchaseModule, addonRemovalModule, apartmentPurchaseModule, + carPurchaseModule, androidPermissionModule, apolloAuthListenersModule, appModule, @@ -346,6 +349,7 @@ val applicationModule = module { notificationModule, paymentsModule, profileModule, + purchaseCommonModule, settingsDatastoreModule, sharedModule(AndroidBuildConfig()), sharedPreferencesModule, 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 215bd380ea..3183778f32 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 @@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.lifecycle.compose.dropUnlessResumed import androidx.media3.datasource.cache.SimpleCache import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.NavHost +import androidx.navigation.toRoute import coil3.ImageLoader import com.benasher44.uuid.Uuid import com.hedvig.android.app.ui.HedvigAppState @@ -16,6 +18,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.data.contract.ContractId +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.design.system.hedvig.motion.MotionDefaults import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseGraphDestination @@ -68,6 +71,12 @@ import com.hedvig.android.feature.movingflow.movingFlowGraph import com.hedvig.android.feature.payments.navigation.paymentsGraph import com.hedvig.android.feature.profile.navigation.ProfileDestination import com.hedvig.android.feature.profile.tab.profileGraph +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph +import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseGraphDestination +import com.hedvig.android.feature.purchase.car.navigation.carPurchaseNavGraph +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceGraphDestination import com.hedvig.android.feature.terminateinsurance.navigation.terminateInsuranceGraph import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateGraphDestination @@ -76,17 +85,15 @@ import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.compose.navdestination 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 +import org.koin.mp.KoinPlatform @Composable internal fun HedvigNavHost( @@ -327,6 +334,9 @@ internal fun HedvigNavHost( onNavigateToApartmentPurchase = { productName -> navController.navigate(ApartmentPurchaseGraphDestination(productName)) }, + onNavigateToCarPurchase = { productName -> + navController.navigate(CarPurchaseGraphDestination(productName)) + }, ) foreverGraph( hedvigDeepLinkContainer = hedvigDeepLinkContainer, @@ -488,6 +498,21 @@ internal fun HedvigNavHost( finishApp = finishApp, crossSellAfterFlowRepository = crossSellAfterFlowRepository, ) + carPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) + navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.popBackStack()) finishApp() + }, + ) + } removeAddonsNavGraph( navController = hedvigAppState.navController, onNavigateToNewConversation = { 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 f7902e706a..228fe87cee 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 @@ -42,6 +42,7 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit, navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit, onNavigateToApartmentPurchase: (productName: String) -> Unit, + onNavigateToCarPurchase: (productName: String) -> Unit, ) { navgraph( startDestination = InsurancesDestination.Insurances::class, From 55206f9bf1bd4172aee00fb401d475071de04dd9 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 19:16:36 +0200 Subject: [PATCH 07/14] fix: rename feature-purchase-common to purchase-common to avoid feature module restriction The hedvig.gradle.plugin prevents feature modules from depending on other feature modules. Moving the shared purchase code out of the feature/ directory makes it a library module that feature modules can depend on. Also fixes: Apollo fragment resolution (each module gets its own fragment), Material3 dropdown replaced with design system DropdownWithDialog. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/app/build.gradle.kts | 2 +- .../build.gradle.kts | 2 +- .../main/graphql/ProductOfferFragment.graphql | 50 +++++++++++++++++ .../feature-purchase-car/build.gradle.kts | 2 +- .../CarPriceIntentConfirmMutation.graphql | 2 +- .../graphql/CarProductOfferFragment.graphql} | 2 +- .../data/SubmitCarFormAndGetOffersUseCase.kt | 4 +- .../car/ui/form/CarFormDestination.kt | 54 +++++++------------ .../build.gradle.kts | 0 .../PurchaseCartEntriesAddMutation.graphql | 0 .../PurchaseShopSessionSigningQuery.graphql | 0 .../graphql/PurchaseStartSignMutation.graphql | 0 .../data/AddToCartAndStartSignUseCase.kt | 0 .../common/data/PollSigningStatusUseCase.kt | 0 .../common/data/PurchaseCommonModels.kt | 0 .../common/di/PurchaseCommonModule.kt | 0 .../navigation/PurchaseCommonDestination.kt | 0 .../ui/failure/PurchaseFailureDestination.kt | 0 .../common/ui/offer/SelectTierDestination.kt | 0 .../common/ui/offer/SelectTierViewModel.kt | 0 .../common/ui/sign/SigningDestination.kt | 6 ++- .../common/ui/sign/SigningViewModel.kt | 0 .../ui/success/PurchaseSuccessDestination.kt | 0 .../ui/summary/PurchaseSummaryDestination.kt | 0 .../ui/summary/PurchaseSummaryViewModel.kt | 0 25 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql rename app/feature/{feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql => feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql} (91%) rename app/{feature/feature-purchase-common => purchase-common}/build.gradle.kts (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/graphql/PurchaseCartEntriesAddMutation.graphql (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/graphql/PurchaseShopSessionSigningQuery.graphql (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/graphql/PurchaseStartSignMutation.graphql (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt (99%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt (100%) rename app/{feature/feature-purchase-common => purchase-common}/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt (100%) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 5ae965aeb8..a6e227bbf8 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -185,7 +185,7 @@ dependencies { implementation(projects.featureAddonPurchase) implementation(projects.featurePurchaseApartment) implementation(projects.featurePurchaseCar) - implementation(projects.featurePurchaseCommon) + implementation(projects.purchaseCommon) implementation(projects.featureChat) implementation(projects.featureChooseTier) implementation(projects.featureClaimChat) diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index 95ac9eed56..0faae925ba 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.dataCrossSellAfterFlow) implementation(projects.designSystemHedvig) - implementation(projects.featurePurchaseCommon) + implementation(projects.purchaseCommon) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCommon) 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-car/build.gradle.kts b/app/feature/feature-purchase-car/build.gradle.kts index 517945e139..b7d142a990 100644 --- a/app/feature/feature-purchase-car/build.gradle.kts +++ b/app/feature/feature-purchase-car/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.dataCrossSellAfterFlow) implementation(projects.designSystemHedvig) - implementation(projects.featurePurchaseCommon) + implementation(projects.purchaseCommon) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql index b829370289..95465ed151 100644 --- a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql @@ -3,7 +3,7 @@ mutation CarPriceIntentConfirm($priceIntentId: UUID!) { priceIntent { id offers { - ...PurchaseProductOfferFragment + ...CarProductOfferFragment } } userError { diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql similarity index 91% rename from app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql rename to app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql index fb261ffc87..5b61c70b67 100644 --- a/app/feature/feature-purchase-common/src/main/graphql/PurchaseProductOfferFragment.graphql +++ b/app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql @@ -1,4 +1,4 @@ -fragment PurchaseProductOfferFragment on ProductOffer { +fragment CarProductOfferFragment on ProductOffer { id variant { displayName diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt index b1cbef16f8..de41566ae1 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt @@ -10,7 +10,7 @@ import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import octopus.CarPriceIntentConfirmMutation import octopus.CarPriceIntentDataUpdateMutation -import octopus.fragment.PurchaseProductOfferFragment +import octopus.fragment.CarProductOfferFragment internal interface SubmitCarFormAndGetOffersUseCase { suspend fun invoke( @@ -90,7 +90,7 @@ internal class SubmitCarFormAndGetOffersUseCaseImpl( } } -internal fun PurchaseProductOfferFragment.toTierOffer(): CarTierOffer { +internal fun CarProductOfferFragment.toTierOffer(): CarTierOffer { return CarTierOffer( offerId = id, tierDisplayName = variant.displayNameTier ?: variant.displayName, diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt index b5c933d727..dc84faa646 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt @@ -7,10 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.MenuAnchorType import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -23,6 +19,10 @@ import androidx.compose.ui.text.input.KeyboardCapitalization 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.DropdownDefaults.DropdownSize +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownStyle +import com.hedvig.android.design.system.hedvig.DropdownItem.SimpleDropdownItem +import com.hedvig.android.design.system.hedvig.DropdownWithDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress @@ -126,7 +126,6 @@ private fun formatRegistrationNumber(input: String): String { return cleaned.take(3) + " " + cleaned.drop(3).take(3) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CarFormContent( ssn: String, @@ -191,36 +190,21 @@ private fun CarFormContent( enabled = !isSubmitting, ) - var mileageExpanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = mileageExpanded, - onExpandedChange = { if (!isSubmitting) mileageExpanded = it }, - ) { - HedvigTextField( - text = selectedMileage?.displayName ?: "", - onValueChange = {}, - labelText = "Miltal", - textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = mileageError.toErrorState(), - readOnly = true, - enabled = !isSubmitting, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable), - ) - ExposedDropdownMenu( - expanded = mileageExpanded, - onDismissRequest = { mileageExpanded = false }, - ) { - MileageOption.entries.forEach { option -> - DropdownMenuItem( - text = { HedvigText(text = option.displayName) }, - onClick = { - onMileageSelected(option) - mileageExpanded = false - }, - ) - } - } - } + DropdownWithDialog( + style = DropdownStyle.Label( + items = MileageOption.entries.map { SimpleDropdownItem(it.displayName) }, + label = "Miltal per \u00e5r", + ), + size = DropdownSize.Medium, + hintText = "V\u00e4lj miltal", + chosenItemIndex = selectedMileage?.let { MileageOption.entries.indexOf(it) }, + onItemChosen = { index -> onMileageSelected(MileageOption.entries[index]) }, + onSelectorClick = {}, + isEnabled = !isSubmitting, + hasError = mileageError != null, + errorText = mileageError, + modifier = Modifier.fillMaxWidth(), + ) HedvigTextField( text = street, diff --git a/app/feature/feature-purchase-common/build.gradle.kts b/app/purchase-common/build.gradle.kts similarity index 100% rename from app/feature/feature-purchase-common/build.gradle.kts rename to app/purchase-common/build.gradle.kts diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql b/app/purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql similarity index 100% rename from app/feature/feature-purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql rename to app/purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql b/app/purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql similarity index 100% rename from app/feature/feature-purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql rename to app/purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql diff --git a/app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql b/app/purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql similarity index 100% rename from app/feature/feature-purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql rename to app/purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt similarity index 99% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt index ab048a2dda..82b79f5aad 100644 --- a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt @@ -64,10 +64,12 @@ fun SigningDestination( hasNavigated = true navigateToSuccess(state.startDate) } + is SigningUiState.Failed -> { hasNavigated = true navigateToFailure() } + is SigningUiState.Polling -> {} } } @@ -97,7 +99,9 @@ fun SigningDestination( is SigningUiState.Success, is SigningUiState.Failed, - -> HedvigFullScreenCenterAlignedProgress() + -> { + HedvigFullScreenCenterAlignedProgress() + } } } diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt diff --git a/app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt similarity index 100% rename from app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt From b137d7634eda49f1f4b783d5a3f0fb1321a5693f Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 19:35:30 +0200 Subject: [PATCH 08/14] fix: remove force-unwrap on nullable mileage and revert cross-sell routing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../android/feature/insurances/navigation/InsuranceGraph.kt | 2 +- .../android/feature/purchase/car/ui/form/CarFormViewModel.kt | 3 ++- 2 files changed, 3 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 228fe87cee..8b84735e25 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 @@ -63,7 +63,7 @@ fun NavGraphBuilder.insuranceGraph( navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId)) }, onCrossSellClick = dropUnlessResumed { url: String -> - // Hardcoded for testing: route all cross-sells to in-app purchase + // TODO: route based on product type from cross-sell data onNavigateToApartmentPurchase("SE_APARTMENT_RENT") }, navigateToCancelledInsurances = dropUnlessResumed { diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt index 6d36c933a2..92a8133630 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt @@ -136,13 +136,14 @@ private class CarFormPresenter( LaunchedEffect(submitIteration) { val submit = pendingSubmit ?: return@LaunchedEffect val session = sessionAndIntent ?: return@LaunchedEffect + val mileage = submit.mileage ?: return@LaunchedEffect pendingSubmit = null currentState = currentState.copy(isSubmitting = true, submitError = null) submitCarFormAndGetOffersUseCase.invoke( priceIntentId = session.priceIntentId, ssn = submit.ssn, registrationNumber = submit.registrationNumber, - mileage = submit.mileage!!, + mileage = mileage, street = submit.street, zipCode = submit.zipCode, email = submit.email, From d3cdda4c7c2ce3d6317a71392f0611e3cb095bdb Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 19:39:48 +0200 Subject: [PATCH 09/14] fix: display submit errors in apartment and car purchase forms Shows a HedvigNotificationCard with error priority when the form submission fails, instead of silently swallowing the error. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apartment/ui/form/ApartmentFormDestination.kt | 15 +++++++++++++++ .../purchase/car/ui/form/CarFormDestination.kt | 14 ++++++++++++++ 2 files changed, 29 insertions(+) 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 c91931782e..971ef2ca7a 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 @@ -22,6 +22,7 @@ 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.HedvigNotificationCard import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigStepper @@ -29,6 +30,7 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.NotificationDefaults 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 @@ -77,6 +79,7 @@ internal fun ApartmentFormDestination( streetError = uiState.streetError, zipCodeError = uiState.zipCodeError, livingSpaceError = uiState.livingSpaceError, + submitError = uiState.submitError, isSubmitting = uiState.isSubmitting, onStreetChanged = { street = it }, onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, @@ -110,6 +113,7 @@ private fun ApartmentFormContent( streetError: String?, zipCodeError: String?, livingSpaceError: String?, + submitError: String?, isSubmitting: Boolean, onStreetChanged: (String) -> Unit, onZipCodeChanged: (String) -> Unit, @@ -180,6 +184,14 @@ private fun ApartmentFormContent( isMinusEnabled = !isSubmitting && numberCoInsured > 0, ) } + if (submitError != null) { + Spacer(Modifier.height(8.dp)) + HedvigNotificationCard( + message = submitError, + priority = NotificationDefaults.NotificationPriority.Error, + modifier = Modifier.fillMaxWidth(), + ) + } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Ber\u00e4kna pris", @@ -213,6 +225,7 @@ private fun PreviewApartmentFormEmpty() { streetError = null, zipCodeError = null, livingSpaceError = null, + submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, @@ -238,6 +251,7 @@ private fun PreviewApartmentFormFilled() { streetError = null, zipCodeError = null, livingSpaceError = null, + submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, @@ -263,6 +277,7 @@ private fun PreviewApartmentFormWithErrors() { streetError = "Ange en adress", zipCodeError = "Ange ett giltigt postnummer (5 siffror)", livingSpaceError = "Ange boyta i kvadratmeter", + submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt index dc84faa646..8f7b101606 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt @@ -26,12 +26,14 @@ import com.hedvig.android.design.system.hedvig.DropdownWithDialog 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.HedvigNotificationCard 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.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.NotificationDefaults import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.car.data.CarOffers @@ -85,6 +87,7 @@ internal fun CarFormDestination( streetError = uiState.streetError, zipCodeError = uiState.zipCodeError, emailError = uiState.emailError, + submitError = uiState.submitError, isSubmitting = uiState.isSubmitting, onSsnChanged = { value -> if (value.length <= 12 && value.all { it.isDigit() }) ssn = value }, onRegistrationNumberChanged = { value -> @@ -140,6 +143,7 @@ private fun CarFormContent( streetError: String?, zipCodeError: String?, emailError: String?, + submitError: String?, isSubmitting: Boolean, onSsnChanged: (String) -> Unit, onRegistrationNumberChanged: (String) -> Unit, @@ -240,6 +244,14 @@ private fun CarFormContent( enabled = !isSubmitting, ) } + if (submitError != null) { + Spacer(Modifier.height(8.dp)) + HedvigNotificationCard( + message = submitError, + priority = NotificationDefaults.NotificationPriority.Error, + modifier = Modifier.fillMaxWidth(), + ) + } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Ber\u00e4kna pris", @@ -278,6 +290,7 @@ private fun PreviewCarFormEmpty() { streetError = null, zipCodeError = null, emailError = null, + submitError = null, isSubmitting = false, onSsnChanged = {}, onRegistrationNumberChanged = {}, @@ -309,6 +322,7 @@ private fun PreviewCarFormFilled() { streetError = null, zipCodeError = null, emailError = null, + submitError = null, isSubmitting = false, onSsnChanged = {}, onRegistrationNumberChanged = {}, From 20ceaf3bd8d75dc854675d8eddd7327e3a01f174 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 19:49:23 +0200 Subject: [PATCH 10/14] fix: add inline error handling to summary and signing screens Summary screen now shows an error notification card when add-to-cart or start-sign fails, instead of navigating to a separate failure screen. Signing screen now shows an error with retry button when polling fails or signing is rejected, instead of navigating away. Removed the Failure destination from both nav graphs since all errors are now handled inline with retry. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../navigation/ApartmentPurchaseNavGraph.kt | 11 ---- .../car/navigation/CarPurchaseNavGraph.kt | 11 ---- .../common/ui/sign/SigningDestination.kt | 53 ++++++++++++++----- .../common/ui/sign/SigningViewModel.kt | 26 +++++++-- .../ui/summary/PurchaseSummaryDestination.kt | 20 ++++--- .../ui/summary/PurchaseSummaryViewModel.kt | 14 ++--- 6 files changed, 80 insertions(+), 55 deletions(-) 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 eaab285478..624c61c73c 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 @@ -100,7 +100,6 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( viewModel = viewModel, navigateUp = dropUnlessResumed { navController.popBackStack() }, navigateToSigning = { params -> navController.navigate(Signing(params)) }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, ) } @@ -119,16 +118,6 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( typedPopUpTo({ inclusive = true }) } }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, - ) - } - - navdestination { - PurchaseFailureDestination( - onRetry = dropUnlessResumed { navController.popBackStack() }, - close = dropUnlessResumed { - if (!navController.typedPopBackStack(inclusive = true)) finishApp() - }, ) } } diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt index 0018e5ab26..0b377d9656 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt @@ -99,7 +99,6 @@ fun NavGraphBuilder.carPurchaseNavGraph( viewModel = viewModel, navigateUp = dropUnlessResumed { navController.popBackStack() }, navigateToSigning = { params -> navController.navigate(Signing(params)) }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, ) } @@ -118,16 +117,6 @@ fun NavGraphBuilder.carPurchaseNavGraph( typedPopUpTo({ inclusive = true }) } }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, - ) - } - - navdestination { - PurchaseFailureDestination( - onRetry = dropUnlessResumed { navController.popBackStack() }, - close = dropUnlessResumed { - if (!navController.typedPopBackStack(inclusive = true)) finishApp() - }, ) } } diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt index 82b79f5aad..530df96879 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt @@ -38,20 +38,18 @@ 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.HedvigNotificationCard 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.NotificationDefaults import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @Composable -fun SigningDestination( - viewModel: SigningViewModel, - navigateToSuccess: (startDate: String?) -> Unit, - navigateToFailure: () -> Unit, -) { +fun SigningDestination(viewModel: SigningViewModel, navigateToSuccess: (startDate: String?) -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val canOpenBankId = remember { canBankIdAppHandleUri(context) } @@ -65,12 +63,7 @@ fun SigningDestination( navigateToSuccess(state.startDate) } - is SigningUiState.Failed -> { - hasNavigated = true - navigateToFailure() - } - - is SigningUiState.Polling -> {} + else -> {} } } @@ -97,9 +90,14 @@ fun SigningDestination( } } - is SigningUiState.Success, - is SigningUiState.Failed, - -> { + is SigningUiState.Failed -> { + SigningErrorScreen( + errorMessage = state.errorMessage, + onRetry = { viewModel.emit(SigningEvent.Retry) }, + ) + } + + is SigningUiState.Success -> { HedvigFullScreenCenterAlignedProgress() } } @@ -146,6 +144,33 @@ private fun QrCodeSigningScreen(liveQrCodeData: String?, onOpenBankId: () -> Uni } } +@Composable +private fun SigningErrorScreen(errorMessage: String?, onRetry: () -> Unit) { + HedvigScaffold(navigateUp = {}) { + Spacer(Modifier.weight(1f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + HedvigNotificationCard( + message = errorMessage ?: "N\u00e5got gick fel vid signeringen", + priority = NotificationDefaults.NotificationPriority.Error, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "F\u00f6rs\u00f6k igen", + onClick = onRetry, + enabled = true, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.weight(1f)) + } +} + @Composable private fun QRCode(data: String, modifier: Modifier = Modifier) { var intSize: IntSize? by remember { mutableStateOf(null) } diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt index 5d144e637f..5d15a331b8 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt @@ -3,6 +3,7 @@ package com.hedvig.android.feature.purchase.common.ui.sign 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 @@ -36,21 +37,34 @@ class SigningPresenter( var bankIdOpened by remember { mutableStateOf((lastState as? SigningUiState.Polling)?.bankIdOpened ?: false) } var currentState by remember { mutableStateOf(lastState) } + var pollIteration by remember { mutableIntStateOf(0) } + CollectEvents { event -> when (event) { SigningEvent.BankIdOpened -> { bankIdOpened = true } + SigningEvent.Retry -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = null, + bankIdOpened = bankIdOpened, + ) + pollIteration++ + } + SigningEvent.ClearNavigation -> {} } } - LaunchedEffect(Unit) { + LaunchedEffect(pollIteration) { + if (currentState is SigningUiState.Failed) return@LaunchedEffect while (true) { pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( - ifLeft = { - currentState = SigningUiState.Failed + ifLeft = { error -> + currentState = SigningUiState.Failed(error.message) return@LaunchedEffect }, ifRight = { pollResult -> @@ -61,7 +75,7 @@ class SigningPresenter( } SigningStatus.FAILED -> { - currentState = SigningUiState.Failed + currentState = SigningUiState.Failed("Signeringen misslyckades") return@LaunchedEffect } @@ -97,11 +111,13 @@ sealed interface SigningUiState { data class Success(val startDate: String?) : SigningUiState - data object Failed : SigningUiState + data class Failed(val errorMessage: String?) : SigningUiState } sealed interface SigningEvent { data object BankIdOpened : SigningEvent + data object Retry : SigningEvent + data object ClearNavigation : SigningEvent } diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt index 12624e3747..5a2d7d17cf 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt @@ -18,11 +18,13 @@ 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.HedvigNotificationCard 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.NotificationDefaults import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.common.navigation.SigningParameters import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters @@ -33,7 +35,6 @@ fun PurchaseSummaryDestination( viewModel: PurchaseSummaryViewModel, navigateUp: () -> Unit, navigateToSigning: (SigningParameters) -> Unit, - navigateToFailure: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -43,15 +44,10 @@ fun PurchaseSummaryDestination( navigateToSigning(signing) } - LaunchedEffect(uiState.navigateToFailure) { - if (!uiState.navigateToFailure) return@LaunchedEffect - viewModel.emit(PurchaseSummaryEvent.ClearNavigation) - navigateToFailure() - } - PurchaseSummaryScreen( params = uiState.params, isSubmitting = uiState.isSubmitting, + submitError = uiState.submitError, navigateUp = navigateUp, onConfirm = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, ) @@ -61,6 +57,7 @@ fun PurchaseSummaryDestination( private fun PurchaseSummaryScreen( params: SummaryParameters, isSubmitting: Boolean, + submitError: String?, navigateUp: () -> Unit, onConfirm: () -> Unit, ) { @@ -138,6 +135,14 @@ private fun PurchaseSummaryScreen( } } Spacer(Modifier.weight(1f)) + if (submitError != null) { + Spacer(Modifier.height(8.dp)) + HedvigNotificationCard( + message = submitError, + priority = NotificationDefaults.NotificationPriority.Error, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Signera med BankID", @@ -176,6 +181,7 @@ private fun PreviewPurchaseSummary() { productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", ), isSubmitting = false, + submitError = null, navigateUp = {}, onConfirm = {}, ) diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt index 324c5adb34..3127af3311 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt @@ -22,7 +22,7 @@ class PurchaseSummaryViewModel( params = summaryParameters, isSubmitting = false, signingToNavigate = null, - navigateToFailure = false, + submitError = null, ), presenter = PurchaseSummaryPresenter( summaryParameters, @@ -41,7 +41,7 @@ class PurchaseSummaryPresenter( 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) } + var submitError by remember { mutableStateOf(lastState.submitError) } CollectEvents { event -> when (event) { @@ -51,7 +51,7 @@ class PurchaseSummaryPresenter( PurchaseSummaryEvent.ClearNavigation -> { signingToNavigate = null - navigateToFailure = false + submitError = null } } } @@ -63,9 +63,9 @@ class PurchaseSummaryPresenter( summaryParameters.shopSessionId, summaryParameters.selectedOffer.offerId, ).fold( - ifLeft = { + ifLeft = { error -> isSubmitting = false - navigateToFailure = true + submitError = error.message ?: "N\u00e5got gick fel" }, ifRight = { signingStart -> isSubmitting = false @@ -83,7 +83,7 @@ class PurchaseSummaryPresenter( params = summaryParameters, isSubmitting = isSubmitting, signingToNavigate = signingToNavigate, - navigateToFailure = navigateToFailure, + submitError = submitError, ) } } @@ -92,7 +92,7 @@ data class PurchaseSummaryUiState( val params: SummaryParameters, val isSubmitting: Boolean, val signingToNavigate: SigningParameters?, - val navigateToFailure: Boolean, + val submitError: String?, ) sealed interface PurchaseSummaryEvent { From a1bddf0e2353829c2d5653a864462b3977974f71 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Wed, 1 Apr 2026 08:56:59 +0200 Subject: [PATCH 11/14] chore: add .worktrees to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d6d77e4dc1..8cdaf30d30 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ lokalise.properties # Local jks store Jks +.worktrees From 831ab85fa8371047ab6ff9067558e9873cb52191 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Wed, 1 Apr 2026 09:06:37 +0200 Subject: [PATCH 12/14] fix: use ErrorDialog for errors, fix reg number input, redesign signing screen, remove form titles Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/form/ApartmentFormDestination.kt | 29 ++-- .../ui/form/ApartmentFormViewModel.kt | 6 + .../car/ui/form/CarFormDestination.kt | 32 ++--- .../purchase/car/ui/form/CarFormViewModel.kt | 11 +- .../common/ui/sign/SigningDestination.kt | 134 +++++++----------- .../ui/summary/PurchaseSummaryDestination.kt | 22 ++- .../ui/summary/PurchaseSummaryViewModel.kt | 6 + 7 files changed, 96 insertions(+), 144 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 971ef2ca7a..c4c07e2d61 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 @@ -19,10 +19,10 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ErrorDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress -import com.hedvig.android.design.system.hedvig.HedvigNotificationCard import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigStepper @@ -30,7 +30,6 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.NotificationDefaults 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 @@ -52,7 +51,6 @@ internal fun ApartmentFormDestination( } HedvigScaffold( navigateUp = navigateUp, - topAppBarText = "Hemförsäkring", ) { when { uiState.isLoadingSession -> { @@ -71,6 +69,13 @@ internal fun ApartmentFormDestination( var livingSpace by remember { mutableStateOf("") } var numberCoInsured by remember { mutableIntStateOf(0) } + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(ApartmentFormEvent.DismissError) }, + ) + } ApartmentFormContent( street = street, zipCode = zipCode, @@ -79,7 +84,6 @@ internal fun ApartmentFormDestination( streetError = uiState.streetError, zipCodeError = uiState.zipCodeError, livingSpaceError = uiState.livingSpaceError, - submitError = uiState.submitError, isSubmitting = uiState.isSubmitting, onStreetChanged = { street = it }, onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, @@ -97,7 +101,6 @@ internal fun ApartmentFormDestination( ), ) }, - onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, ) } } @@ -113,14 +116,12 @@ private fun ApartmentFormContent( streetError: String?, zipCodeError: String?, livingSpaceError: String?, - submitError: String?, isSubmitting: Boolean, onStreetChanged: (String) -> Unit, onZipCodeChanged: (String) -> Unit, onLivingSpaceChanged: (String) -> Unit, onNumberCoInsuredChanged: (Int) -> Unit, onSubmit: () -> Unit, - onRetry: () -> Unit, ) { Column( modifier = Modifier @@ -184,14 +185,6 @@ private fun ApartmentFormContent( isMinusEnabled = !isSubmitting && numberCoInsured > 0, ) } - if (submitError != null) { - Spacer(Modifier.height(8.dp)) - HedvigNotificationCard( - message = submitError, - priority = NotificationDefaults.NotificationPriority.Error, - modifier = Modifier.fillMaxWidth(), - ) - } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Ber\u00e4kna pris", @@ -225,14 +218,12 @@ private fun PreviewApartmentFormEmpty() { streetError = null, zipCodeError = null, livingSpaceError = null, - submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } @@ -251,14 +242,12 @@ private fun PreviewApartmentFormFilled() { streetError = null, zipCodeError = null, livingSpaceError = null, - submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } @@ -277,14 +266,12 @@ private fun PreviewApartmentFormWithErrors() { streetError = "Ange en adress", zipCodeError = "Ange ett giltigt postnummer (5 siffror)", livingSpaceError = "Ange boyta i kvadratmeter", - submitError = null, isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } 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 02c5a6e1da..799d2d730a 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 @@ -35,6 +35,8 @@ internal sealed interface ApartmentFormEvent { data object ClearNavigation : ApartmentFormEvent data object Retry : ApartmentFormEvent + + data object DismissError : ApartmentFormEvent } internal data class ApartmentFormState( @@ -99,6 +101,10 @@ private class ApartmentFormPresenter( currentState = currentState.copy(submitError = null) } } + + ApartmentFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } } } diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt index 8f7b101606..7a063599dc 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt @@ -23,17 +23,16 @@ import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownSize import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownStyle import com.hedvig.android.design.system.hedvig.DropdownItem.SimpleDropdownItem import com.hedvig.android.design.system.hedvig.DropdownWithDialog +import com.hedvig.android.design.system.hedvig.ErrorDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress -import com.hedvig.android.design.system.hedvig.HedvigNotificationCard 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.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.NotificationDefaults import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.car.data.CarOffers @@ -53,7 +52,6 @@ internal fun CarFormDestination( } HedvigScaffold( navigateUp = navigateUp, - topAppBarText = "Bilf\u00f6rs\u00e4kring", ) { when { uiState.isLoadingSession -> { @@ -74,6 +72,13 @@ internal fun CarFormDestination( var zipCode by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(CarFormEvent.DismissError) }, + ) + } CarFormContent( ssn = ssn, registrationNumber = registrationNumber, @@ -87,11 +92,11 @@ internal fun CarFormDestination( streetError = uiState.streetError, zipCodeError = uiState.zipCodeError, emailError = uiState.emailError, - submitError = uiState.submitError, isSubmitting = uiState.isSubmitting, onSsnChanged = { value -> if (value.length <= 12 && value.all { it.isDigit() }) ssn = value }, onRegistrationNumberChanged = { value -> - registrationNumber = formatRegistrationNumber(value) + val filtered = value.uppercase().filter { it.isLetterOrDigit() || it == ' ' } + if (filtered.length <= 7) registrationNumber = filtered }, onMileageSelected = { selectedMileage = it }, onStreetChanged = { street = it }, @@ -123,12 +128,6 @@ internal enum class MileageOption(val value: Int, val displayName: String) { MILEAGE_2500_PLUS(2501, "2 500+ mil"), } -private fun formatRegistrationNumber(input: String): String { - val cleaned = input.uppercase().filter { it.isLetterOrDigit() } - if (cleaned.length <= 3) return cleaned - return cleaned.take(3) + " " + cleaned.drop(3).take(3) -} - @Composable private fun CarFormContent( ssn: String, @@ -143,7 +142,6 @@ private fun CarFormContent( streetError: String?, zipCodeError: String?, emailError: String?, - submitError: String?, isSubmitting: Boolean, onSsnChanged: (String) -> Unit, onRegistrationNumberChanged: (String) -> Unit, @@ -244,14 +242,6 @@ private fun CarFormContent( enabled = !isSubmitting, ) } - if (submitError != null) { - Spacer(Modifier.height(8.dp)) - HedvigNotificationCard( - message = submitError, - priority = NotificationDefaults.NotificationPriority.Error, - modifier = Modifier.fillMaxWidth(), - ) - } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Ber\u00e4kna pris", @@ -290,7 +280,6 @@ private fun PreviewCarFormEmpty() { streetError = null, zipCodeError = null, emailError = null, - submitError = null, isSubmitting = false, onSsnChanged = {}, onRegistrationNumberChanged = {}, @@ -322,7 +311,6 @@ private fun PreviewCarFormFilled() { streetError = null, zipCodeError = null, emailError = null, - submitError = null, isSubmitting = false, onSsnChanged = {}, onRegistrationNumberChanged = {}, diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt index 92a8133630..b1adcfa667 100644 --- a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt @@ -37,6 +37,8 @@ internal sealed interface CarFormEvent { data object ClearNavigation : CarFormEvent data object Retry : CarFormEvent + + data object DismissError : CarFormEvent } internal data class CarFormState( @@ -117,6 +119,10 @@ private class CarFormPresenter( currentState = currentState.copy(submitError = null) } } + + CarFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } } } @@ -186,7 +192,6 @@ private data class ValidationErrors( emailError != null } -private val REGISTRATION_NUMBER_REGEX = Regex("^[A-Z]{3} \\d{2}[A-Z0-9]$") private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") private fun validate( @@ -204,8 +209,8 @@ private fun validate( else -> null }, registrationNumberError = when { - registrationNumber.isBlank() -> "Ange ett registreringsnummer" - !REGISTRATION_NUMBER_REGEX.matches(registrationNumber) -> "Ange ett giltigt registreringsnummer (t.ex. ABC 12D)" + registrationNumber.isBlank() -> "Ange registreringsnummer" + registrationNumber.replace(" ", "").length != 6 -> "Ange ett giltigt registreringsnummer (t.ex. ABC 123)" else -> null }, mileageError = if (mileage == null) "V\u00e4lj miltal" else null, diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt index 530df96879..131b3cec3f 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt @@ -30,19 +30,19 @@ 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.text.style.TextAlign 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.ButtonDefaults.ButtonSize.Large 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.HedvigNotificationCard -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.NotificationDefaults import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import kotlinx.coroutines.Dispatchers @@ -75,25 +75,57 @@ fun SigningDestination(viewModel: SigningViewModel, navigateToSuccess: (startDat 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) - }, + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.weight(1f)) + if (state.liveQrCodeData != null) { + QRCode( + data = state.liveQrCodeData, + modifier = Modifier.size(180.dp), + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } + Spacer(Modifier.height(32.dp)) + HedvigText( + text = "Signera med BankID", + style = HedvigTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + HedvigText( + text = "Skanna QR-koden med BankID-appen", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, ) - } else { - HedvigFullScreenCenterAlignedProgress() + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) + if (canOpenBankId) { + HedvigButton( + text = "\u00d6ppna BankID", + onClick = { + val bankIdUri = + Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + }, + enabled = true, + buttonSize = Large, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } } } is SigningUiState.Failed -> { - SigningErrorScreen( - errorMessage = state.errorMessage, - onRetry = { viewModel.emit(SigningEvent.Retry) }, + HedvigErrorSection( + onButtonClick = { viewModel.emit(SigningEvent.Retry) }, ) } @@ -103,74 +135,6 @@ fun SigningDestination(viewModel: SigningViewModel, navigateToSuccess: (startDat } } -@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\u00e5 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 = "\u00d6ppna BankID", - onClick = onOpenBankId, - enabled = true, - modifier = Modifier.fillMaxWidth(), - ) - } - Spacer(Modifier.weight(1f)) - } -} - -@Composable -private fun SigningErrorScreen(errorMessage: String?, onRetry: () -> Unit) { - HedvigScaffold(navigateUp = {}) { - Spacer(Modifier.weight(1f)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - HedvigNotificationCard( - message = errorMessage ?: "N\u00e5got gick fel vid signeringen", - priority = NotificationDefaults.NotificationPriority.Error, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - HedvigButton( - text = "F\u00f6rs\u00f6k igen", - onClick = onRetry, - enabled = true, - modifier = Modifier.fillMaxWidth(), - ) - } - Spacer(Modifier.weight(1f)) - } -} - @Composable private fun QRCode(data: String, modifier: Modifier = Modifier) { var intSize: IntSize? by remember { mutableStateOf(null) } diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt index 5a2d7d17cf..4235029cfb 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt @@ -16,15 +16,14 @@ 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.ErrorDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigCard -import com.hedvig.android.design.system.hedvig.HedvigNotificationCard 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.NotificationDefaults import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.common.navigation.SigningParameters import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters @@ -44,10 +43,17 @@ fun PurchaseSummaryDestination( navigateToSigning(signing) } + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(PurchaseSummaryEvent.DismissError) }, + ) + } + PurchaseSummaryScreen( params = uiState.params, isSubmitting = uiState.isSubmitting, - submitError = uiState.submitError, navigateUp = navigateUp, onConfirm = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, ) @@ -57,7 +63,6 @@ fun PurchaseSummaryDestination( private fun PurchaseSummaryScreen( params: SummaryParameters, isSubmitting: Boolean, - submitError: String?, navigateUp: () -> Unit, onConfirm: () -> Unit, ) { @@ -135,14 +140,6 @@ private fun PurchaseSummaryScreen( } } Spacer(Modifier.weight(1f)) - if (submitError != null) { - Spacer(Modifier.height(8.dp)) - HedvigNotificationCard( - message = submitError, - priority = NotificationDefaults.NotificationPriority.Error, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) - } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Signera med BankID", @@ -181,7 +178,6 @@ private fun PreviewPurchaseSummary() { productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", ), isSubmitting = false, - submitError = null, navigateUp = {}, onConfirm = {}, ) diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt index 3127af3311..1f1f67c5fd 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt @@ -53,6 +53,10 @@ class PurchaseSummaryPresenter( signingToNavigate = null submitError = null } + + PurchaseSummaryEvent.DismissError -> { + submitError = null + } } } @@ -99,4 +103,6 @@ sealed interface PurchaseSummaryEvent { data object Confirm : PurchaseSummaryEvent data object ClearNavigation : PurchaseSummaryEvent + + data object DismissError : PurchaseSummaryEvent } From 2f102bf8072dd1ed0029057b63ede136ada93171 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Wed, 1 Apr 2026 09:14:34 +0200 Subject: [PATCH 13/14] refactor: redesign tier selection to use dropdowns matching change-tier flow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/ui/offer/SelectTierDestination.kt | 366 +++++++++++------- .../common/ui/offer/SelectTierViewModel.kt | 76 +++- 2 files changed, 305 insertions(+), 137 deletions(-) diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt index d008fceadf..2b9e889fbf 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt @@ -1,37 +1,39 @@ package com.hedvig.android.feature.purchase.common.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.border 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.size -import androidx.compose.foundation.layout.width +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.draw.shadow +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownSize.Small +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownStyle.Label +import com.hedvig.android.design.system.hedvig.DropdownItem.SimpleDropdownItem +import com.hedvig.android.design.system.hedvig.DropdownWithDialog 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.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken import com.hedvig.android.design.system.hedvig.RadioGroup -import com.hedvig.android.design.system.hedvig.RadioGroupSize +import com.hedvig.android.design.system.hedvig.RadioGroupStyle import com.hedvig.android.design.system.hedvig.RadioOption import com.hedvig.android.design.system.hedvig.RadioOptionId -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters import java.text.NumberFormat import java.util.Currency @@ -53,10 +55,12 @@ fun SelectTierDestination( SelectTierContent( uiState = uiState, navigateUp = navigateUp, - onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, - onSelectDeductible = { tierName, offerId -> - viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) - }, + onSelectTierInDialog = { viewModel.emit(SelectTierEvent.SelectTierInDialog(it)) }, + onConfirmTier = { viewModel.emit(SelectTierEvent.ConfirmTier) }, + onRevertTier = { viewModel.emit(SelectTierEvent.RevertTierToConfirmed) }, + onSelectDeductibleInDialog = { viewModel.emit(SelectTierEvent.SelectDeductibleInDialog(it)) }, + onConfirmDeductible = { viewModel.emit(SelectTierEvent.ConfirmDeductible) }, + onRevertDeductible = { viewModel.emit(SelectTierEvent.RevertDeductibleToConfirmed) }, onContinue = { viewModel.emit(SelectTierEvent.Continue) }, ) } @@ -65,8 +69,12 @@ fun SelectTierDestination( private fun SelectTierContent( uiState: SelectTierUiState, navigateUp: () -> Unit = {}, - onSelectTier: (String) -> Unit = {}, - onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, + onSelectTierInDialog: (String) -> Unit = {}, + onConfirmTier: () -> Unit = {}, + onRevertTier: () -> Unit = {}, + onSelectDeductibleInDialog: (String) -> Unit = {}, + onConfirmDeductible: () -> Unit = {}, + onRevertDeductible: () -> Unit = {}, onContinue: () -> Unit = {}, ) { HedvigScaffold( @@ -80,34 +88,27 @@ private fun SelectTierContent( ) Spacer(Modifier.height(4.dp)) HedvigText( - text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", + text = "V\u00e4lj skyddsniv\u00e5 och sj\u00e4lvrisk", style = HedvigTheme.typography.bodyMedium, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier.padding(horizontal = 16.dp), ) - Spacer(Modifier.height(24.dp)) - 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.tierGroups.lastIndex) { - Spacer(Modifier.height(12.dp)) - } - } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.weight(1f)) + CustomizationCard( + uiState = uiState, + onSelectTierInDialog = onSelectTierInDialog, + onConfirmTier = onConfirmTier, + onRevertTier = onRevertTier, + onSelectDeductibleInDialog = onSelectDeductibleInDialog, + onConfirmDeductible = onConfirmDeductible, + onRevertDeductible = onRevertDeductible, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) HedvigButton( text = "Forts\u00e4tt", onClick = dropUnlessResumed { onContinue() }, - enabled = uiState.selectedTierName.isNotEmpty(), + enabled = uiState.selectedTierName.isNotEmpty() && uiState.selectedDeductible != null, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -117,109 +118,212 @@ private fun SelectTierContent( } @Composable -private fun TierGroupCard( - tierGroup: TierGroup, - isSelected: Boolean, - selectedDeductibleId: String, - onSelectTier: () -> Unit, - onSelectDeductible: (String) -> Unit, +private fun CustomizationCard( + uiState: SelectTierUiState, + onSelectTierInDialog: (String) -> Unit, + onConfirmTier: () -> Unit, + onRevertTier: () -> Unit, + onSelectDeductibleInDialog: (String) -> Unit, + onConfirmDeductible: () -> Unit, + onRevertDeductible: () -> Unit, modifier: Modifier = Modifier, ) { - val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } - HedvigCard( - onClick = onSelectTier, - borderColor = if (isSelected) { - HedvigTheme.colorScheme.signalGreenElement - } else { - HedvigTheme.colorScheme.borderSecondary - }, - modifier = modifier, + Surface( + modifier = modifier + .shadow(elevation = 2.dp, shape = HedvigTheme.shapes.cornerXLarge) + .border( + shape = HedvigTheme.shapes.cornerXLarge, + color = HedvigTheme.colorScheme.borderPrimary, + width = 1.dp, + ), + shape = HedvigTheme.shapes.cornerXLarge, + color = HedvigTheme.colorScheme.backgroundPrimary, ) { Column(Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - HedvigText( - text = tierGroup.tierDisplayName, - style = HedvigTheme.typography.bodyLarge, + DropdownWithDialog( + dialogProperties = DialogProperties(usePlatformDefaultWidth = false), + style = Label( + label = "Skyddsniv\u00e5", + items = uiState.tierGroups.map { SimpleDropdownItem(it.tierDisplayName) }, + ), + size = Small, + hintText = "V\u00e4lj skyddsniv\u00e5", + chosenItemIndex = uiState.selectedTierIndex, + onDoAlongWithDismissRequest = onRevertTier, + containerColor = HedvigTheme.colorScheme.surfacePrimary, + ) { onDismissRequest -> + TierDialogContent( + tierGroups = uiState.tierGroups, + dialogTierName = uiState.dialogTierName, + onSelectTierInDialog = onSelectTierInDialog, + onConfirm = { + onConfirmTier() + onDismissRequest() + }, + onCancel = onDismissRequest, ) - if (selectedOption != null) { - HedvigText( - text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), - style = HedvigTheme.typography.bodyLarge, - ) - } } - AnimatedVisibility( - visible = isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - if (tierGroup.usps.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - for (usp in tierGroup.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, - ) - } - } - } - 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, - ) - } - } + Spacer(Modifier.height(4.dp)) + DropdownWithDialog( + dialogProperties = DialogProperties(usePlatformDefaultWidth = false), + isEnabled = uiState.currentDeductibleOptions.size > 1, + style = Label( + label = "Sj\u00e4lvrisk", + items = uiState.currentDeductibleOptions.map { SimpleDropdownItem(it.deductibleDisplayName) }, + ), + size = Small, + hintText = "V\u00e4lj sj\u00e4lvrisk", + chosenItemIndex = uiState.selectedDeductibleIndex, + onDoAlongWithDismissRequest = onRevertDeductible, + containerColor = HedvigTheme.colorScheme.surfacePrimary, + ) { onDismissRequest -> + DeductibleDialogContent( + deductibleOptions = uiState.currentDeductibleOptions, + dialogDeductibleId = uiState.dialogDeductibleId, + onSelectDeductibleInDialog = onSelectDeductibleInDialog, + onConfirm = { + onConfirmDeductible() + onDismissRequest() + }, + onCancel = onDismissRequest, + ) } - AnimatedVisibility( - visible = !isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) + val selectedDeductible = uiState.selectedDeductible + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { HedvigText( - text = "V\u00e4lj ${tierGroup.tierDisplayName}", - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, + "Totalt", + style = HedvigTheme.typography.bodySmall, ) - } - } + }, + spaceBetween = 8.dp, + endSlot = { + HedvigText( + text = if (selectedDeductible != null) { + formatPrice(selectedDeductible.netAmount, selectedDeductible.netCurrencyCode) + } else { + "-" + }, + textAlign = TextAlign.End, + style = HedvigTheme.typography.bodySmall, + ) + }, + ) } } } +@Composable +private fun TierDialogContent( + tierGroups: List, + dialogTierName: String?, + onSelectTierInDialog: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + "V\u00e4lj skyddsniv\u00e5", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + HedvigText( + "Skyddsniv\u00e5n avg\u00f6r vad din f\u00f6rs\u00e4kring t\u00e4cker", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + RadioGroup( + options = tierGroups.map { group -> + RadioOption( + id = RadioOptionId(group.tierDisplayName), + text = group.tierDisplayName, + label = group.tierDescription, + ) + }, + selectedOption = dialogTierName?.let { RadioOptionId(it) }, + onRadioOptionSelected = { onSelectTierInDialog(it.id) }, + style = RadioGroupStyle.LeftAligned, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = onConfirm, + enabled = dialogTierName != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = "Avbryt", + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + onClick = onCancel, + ) + } +} + +@Composable +private fun DeductibleDialogContent( + deductibleOptions: List, + dialogDeductibleId: String?, + onSelectDeductibleInDialog: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + "V\u00e4lj sj\u00e4lvrisk", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + HedvigText( + "En h\u00f6gre sj\u00e4lvrisk ger l\u00e4gre m\u00e5nadskostnad", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + RadioGroup( + options = deductibleOptions.map { option -> + RadioOption( + id = RadioOptionId(option.offerId), + text = option.deductibleDisplayName, + label = formatPrice(option.netAmount, option.netCurrencyCode), + ) + }, + selectedOption = dialogDeductibleId?.let { RadioOptionId(it) }, + onRadioOptionSelected = { onSelectDeductibleInDialog(it.id) }, + style = RadioGroupStyle.LeftAligned, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = onConfirm, + enabled = dialogDeductibleId != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = "Avbryt", + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + onClick = onCancel, + ) + } +} + private fun formatPrice(amount: Double, currencyCode: String): String { @Suppress("DEPRECATION") val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) @@ -281,6 +385,8 @@ private fun PreviewSelectTierStandard() { "Hem Standard" to "2a", "Hem Bas" to "3c", ), + dialogTierName = null, + dialogDeductibleId = null, shopSessionId = "session", productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", summaryToNavigate = null, diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt index 67b39d4a45..dc63a37a25 100644 --- a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt @@ -31,6 +31,8 @@ private fun buildInitialState(params: SelectTierParameters): SelectTierUiState { tierGroups = tierGroups, selectedTierName = defaultTierName, selectedDeductibleByTier = defaultDeductibleByTier, + dialogTierName = null, + dialogDeductibleId = null, shopSessionId = params.shopSessionId, productDisplayName = params.productDisplayName, summaryToNavigate = null, @@ -66,16 +68,45 @@ class SelectTierPresenter( override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { var selectedTierName by remember { mutableStateOf(lastState.selectedTierName) } var selectedDeductibleByTier by remember { mutableStateOf(lastState.selectedDeductibleByTier) } + var dialogTierName: String? by remember { mutableStateOf(lastState.dialogTierName) } + var dialogDeductibleId: String? by remember { mutableStateOf(lastState.dialogDeductibleId) } var summaryToNavigate: SummaryParameters? by remember { mutableStateOf(lastState.summaryToNavigate) } CollectEvents { event -> when (event) { - is SelectTierEvent.SelectTier -> { - selectedTierName = event.tierName + is SelectTierEvent.SelectTierInDialog -> { + dialogTierName = event.tierName } - is SelectTierEvent.SelectDeductible -> { - selectedDeductibleByTier = selectedDeductibleByTier + (event.tierName to event.offerId) + is SelectTierEvent.SelectDeductibleInDialog -> { + dialogDeductibleId = event.offerId + } + + SelectTierEvent.ConfirmTier -> { + val newTierName = dialogTierName ?: return@CollectEvents + selectedTierName = newTierName + val group = lastState.tierGroups.firstOrNull { it.tierDisplayName == newTierName } + if (group != null) { + val cheapest = group.deductibleOptions.minByOrNull { it.netAmount } + if (cheapest != null) { + selectedDeductibleByTier = selectedDeductibleByTier + (newTierName to cheapest.offerId) + } + } + dialogTierName = null + } + + SelectTierEvent.ConfirmDeductible -> { + val newDeductibleId = dialogDeductibleId ?: return@CollectEvents + selectedDeductibleByTier = selectedDeductibleByTier + (selectedTierName to newDeductibleId) + dialogDeductibleId = null + } + + SelectTierEvent.RevertTierToConfirmed -> { + dialogTierName = null + } + + SelectTierEvent.RevertDeductibleToConfirmed -> { + dialogDeductibleId = null } SelectTierEvent.Continue -> { @@ -98,6 +129,8 @@ class SelectTierPresenter( tierGroups = lastState.tierGroups, selectedTierName = selectedTierName, selectedDeductibleByTier = selectedDeductibleByTier, + dialogTierName = dialogTierName, + dialogDeductibleId = dialogDeductibleId, shopSessionId = params.shopSessionId, productDisplayName = params.productDisplayName, summaryToNavigate = summaryToNavigate, @@ -126,15 +159,44 @@ data class SelectTierUiState( val tierGroups: List, val selectedTierName: String, val selectedDeductibleByTier: Map, + val dialogTierName: String?, + val dialogDeductibleId: String?, val shopSessionId: String, val productDisplayName: String, val summaryToNavigate: SummaryParameters?, -) +) { + val selectedTierIndex: Int? + get() = tierGroups.indexOfFirst { it.tierDisplayName == selectedTierName }.takeIf { it >= 0 } + + val selectedDeductibleIndex: Int? + get() { + val group = tierGroups.firstOrNull { it.tierDisplayName == selectedTierName } ?: return null + val deductibleId = selectedDeductibleByTier[selectedTierName] ?: return null + return group.deductibleOptions.indexOfFirst { it.offerId == deductibleId }.takeIf { it >= 0 } + } + + val currentDeductibleOptions: List + get() = tierGroups.firstOrNull { it.tierDisplayName == selectedTierName }?.deductibleOptions ?: emptyList() + + val selectedDeductible: DeductibleOption? + get() { + val deductibleId = selectedDeductibleByTier[selectedTierName] ?: return null + return currentDeductibleOptions.firstOrNull { it.offerId == deductibleId } + } +} sealed interface SelectTierEvent { - data class SelectTier(val tierName: String) : SelectTierEvent + data class SelectTierInDialog(val tierName: String) : SelectTierEvent + + data class SelectDeductibleInDialog(val offerId: String) : SelectTierEvent + + data object ConfirmTier : SelectTierEvent + + data object ConfirmDeductible : SelectTierEvent + + data object RevertTierToConfirmed : SelectTierEvent - data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent + data object RevertDeductibleToConfirmed : SelectTierEvent data object Continue : SelectTierEvent From 25cc6fc2e28555cd42491d759a7c437d55ae8b55 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Fri, 22 May 2026 14:13:02 +0200 Subject: [PATCH 14/14] eng: wire up AGP Compose Preview screenshot tests in feature-purchase-apartment Adds the com.android.compose.screenshot plugin (alpha15) so @PreviewTest composables render to PNGs on the JVM via LayoutLib. Sets the apartment form's filled-state preview as the first screenshot test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../build.gradle.kts | 6 ++++ .../ui/form/ApartmentFormDestination.kt | 2 +- .../ui/form/ApartmentFormScreenshotTest.kt | 32 ++++++++++++++++++ ...nshot_apartment_form_filled_d20b0d32_0.png | Bin 0 -> 57393 bytes build.gradle.kts | 1 + gradle.properties | 5 ++- gradle/libs.versions.toml | 3 ++ 7 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt create mode 100644 app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index 0faae925ba..06854102a8 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("hedvig.android.library") id("hedvig.gradle.plugin") + alias(libs.plugins.composeScreenshot) } hedvig { @@ -11,6 +12,7 @@ hedvig { android { testOptions.unitTests.isReturnDefaultValues = true + experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { @@ -40,6 +42,10 @@ dependencies { implementation(projects.navigationComposeTyped) implementation(projects.navigationCore) + screenshotTestImplementation(libs.androidx.compose.uiTooling) + screenshotTestImplementation(libs.androidx.compose.uiToolingPreview) + screenshotTestImplementation(libs.composeScreenshotValidationApi) + testImplementation(libs.apollo.testingSupport) testImplementation(libs.assertK) testImplementation(libs.coroutines.test) 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 c4c07e2d61..0ebc9aaa22 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 @@ -108,7 +108,7 @@ internal fun ApartmentFormDestination( } @Composable -private fun ApartmentFormContent( +internal fun ApartmentFormContent( street: String, zipCode: String, livingSpace: String, diff --git a/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt b/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt new file mode 100644 index 0000000000..2b9730943e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface + +@PreviewTest +@Preview(name = "apartment_form_filled", showBackground = true) +@Composable +fun PreviewApartmentFormFilledScreenshot() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + street = "Storgatan 1", + zipCode = "12345", + livingSpace = "65", + numberCoInsured = 1, + streetError = null, + zipCodeError = null, + livingSpaceError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png b/app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png new file mode 100644 index 0000000000000000000000000000000000000000..6225f4052ab45d3c835991c58ef663d22f35733c GIT binary patch literal 57393 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 gApbuB0j&Id*tN?~m1vX