diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 4047592715..c754732dc1 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -183,6 +183,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.designSystemInternals) implementation(projects.featureAddonPurchase) + implementation(projects.featurePurchaseApartment) implementation(projects.featureChat) implementation(projects.featureChooseTier) implementation(projects.featureClaimChat) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt index 24d0169d18..db0104ab6a 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt @@ -63,6 +63,7 @@ import com.hedvig.android.datadog.core.di.datadogModule import com.hedvig.android.datadog.demo.tracking.di.datadogDemoTrackingModule import com.hedvig.android.design.system.hedvig.pdfrenderer.PdfDecoder import com.hedvig.android.feature.addon.purchase.di.addonPurchaseModule +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule import com.hedvig.android.feature.change.tier.di.chooseTierModule import com.hedvig.android.feature.chat.di.chatModule import com.hedvig.android.feature.claim.details.di.claimDetailsModule @@ -290,6 +291,7 @@ val applicationModule = module { listOf( addonPurchaseModule, addonRemovalModule, + apartmentPurchaseModule, androidPermissionModule, apolloAuthListenersModule, appModule, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt index 886e28e1ed..215bd380ea 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt @@ -79,8 +79,12 @@ import com.hedvig.android.navigation.common.Destination import com.hedvig.android.navigation.compose.typedPopBackStack import com.hedvig.android.navigation.compose.typedPopUpTo import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import org.koin.mp.KoinPlatform import com.hedvig.feature.claim.chat.ClaimChatDestination import com.hedvig.feature.claim.chat.claimChatGraph +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph import com.hedvig.feature.remove.addons.AddonRemoveGraphDestination import com.hedvig.feature.remove.addons.removeAddonsNavGraph @@ -135,6 +139,8 @@ internal fun HedvigNavHost( hedvigAppState.navController.navigate(ImageViewer(imageUrl, cacheKey)) } + val crossSellAfterFlowRepository = KoinPlatform.getKoin().get() + NavHost( navController = navController, startDestination = HomeDestination.Graph::class, @@ -318,6 +324,9 @@ internal fun HedvigNavHost( ), ) }, + onNavigateToApartmentPurchase = { productName -> + navController.navigate(ApartmentPurchaseGraphDestination(productName)) + }, ) foreverGraph( hedvigDeepLinkContainer = hedvigDeepLinkContainer, @@ -473,6 +482,12 @@ internal fun HedvigNavHost( tryToDialPhone = externalNavigator::tryToDialPhone, ) imageViewerGraph(navController, imageLoader) + apartmentPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) removeAddonsNavGraph( navController = hedvigAppState.navController, onNavigateToNewConversation = { diff --git a/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt b/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt index f3ffd2f1d6..40f05139b3 100644 --- a/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt +++ b/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt @@ -67,6 +67,11 @@ sealed class CrossSellInfoType() { override val loggableName: String = "moveFlow" override val extraInfo: Map? = null } + + data object Purchase : CrossSellInfoType() { + override val loggableName: String = "purchase" + override val extraInfo: Map? = null + } } class CrossSellAfterFlowRepositoryImpl() : CrossSellAfterFlowRepository { diff --git a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt index e80c1ef7f2..7ebda9897c 100644 --- a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt +++ b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt @@ -118,6 +118,7 @@ internal fun CrossSellInfoType.toCrossSellSource(): CrossSellInput { is CrossSellInfoType.ClosedClaim -> smartCrossSellInput(FlowSource.CLOSED_CLAIM) CrossSellInfoType.EditCoInsured -> smartCrossSellInput(FlowSource.EDIT_COINSURED) CrossSellInfoType.MovingFlow -> smartCrossSellInput(FlowSource.MOVING) + CrossSellInfoType.Purchase -> smartCrossSellInput(FlowSource.UNKNOWN__) } } diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt index 3a6dac56ea..f7902e706a 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt @@ -20,6 +20,7 @@ import com.hedvig.android.navigation.compose.navDeepLinks import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.navgraph import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import java.net.URLDecoder import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -40,6 +41,7 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToAddonPurchaseFlow: (List, AvailableAddon?) -> Unit, onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit, navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit, + onNavigateToApartmentPurchase: (productName: String) -> Unit, ) { navgraph( startDestination = InsurancesDestination.Insurances::class, @@ -59,7 +61,10 @@ fun NavGraphBuilder.insuranceGraph( onInsuranceCardClick = dropUnlessResumed { contractId: String -> navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId)) }, - onCrossSellClick = dropUnlessResumed { url: String -> openUrl(url) }, + onCrossSellClick = dropUnlessResumed { url: String -> + // Hardcoded for testing: route all cross-sells to in-app purchase + onNavigateToApartmentPurchase("SE_APARTMENT_RENT") + }, navigateToCancelledInsurances = dropUnlessResumed { navController.navigate(InsurancesDestinations.TerminatedInsurances) }, @@ -117,3 +122,17 @@ fun NavGraphBuilder.insuranceGraph( } } } + +private fun parseApartmentProductFromUrl(url: String): String? { + val decodedUrl = try { + URLDecoder.decode(url, "UTF-8") + } catch (_: Exception) { + url + } + val lowerUrl = decodedUrl.lowercase() + return when { + lowerUrl.contains("hyresratt") || lowerUrl.contains("home-insurance/rental") -> "SE_APARTMENT_RENT" + lowerUrl.contains("bostadsratt") || lowerUrl.contains("home-insurance/homeowner") -> "SE_APARTMENT_BRF" + else -> null + } +} diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts new file mode 100644 index 0000000000..58daa4a1f0 --- /dev/null +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -0,0 +1,53 @@ +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.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.languageCore) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql new file mode 100644 index 0000000000..12a94faf01 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql @@ -0,0 +1,13 @@ +mutation ApartmentPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...ApartmentProductOfferFragment + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql new file mode 100644 index 0000000000..70008bace5 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation ApartmentPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql new file mode 100644 index 0000000000..accd624727 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql @@ -0,0 +1,10 @@ +mutation ApartmentPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql new file mode 100644 index 0000000000..d436604c50 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql @@ -0,0 +1,50 @@ +fragment ApartmentProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql new file mode 100644 index 0000000000..4591859668 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql @@ -0,0 +1,10 @@ +mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql new file mode 100644 index 0000000000..9e124e87c5 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation ApartmentShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql new file mode 100644 index 0000000000..bc326afc0f --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql @@ -0,0 +1,17 @@ +query ApartmentShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql new file mode 100644 index 0000000000..0067b7708d --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql @@ -0,0 +1,19 @@ +mutation ApartmentStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt new file mode 100644 index 0000000000..ef7705d31e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentCartEntriesAddMutation +import octopus.ApartmentStartSignMutation + +internal interface AddToCartAndStartSignUseCase { + suspend fun invoke(shopSessionId: String, offerId: String): Either +} + +internal class AddToCartAndStartSignUseCaseImpl( + private val apolloClient: ApolloClient, +) : AddToCartAndStartSignUseCase { + override suspend fun invoke(shopSessionId: String, offerId: String): Either { + return either { + val cartResult = apolloClient + .mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to add to cart: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCartEntriesAdd }, + ) + + if (cartResult.userError != null) { + raise(ErrorMessage(cartResult.userError?.message)) + } + + val signResult = apolloClient + .mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to start signing: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionStartSign }, + ) + + if (signResult.userError != null) { + raise(ErrorMessage(signResult.userError?.message)) + } + + val signing = signResult.signing ?: run { + logcat(LogPriority.ERROR) { "No signing session returned" } + raise(ErrorMessage()) + } + + val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run { + logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" } + raise(ErrorMessage()) + } + + SigningStart( + signingId = signing.id, + autoStartToken = autoStartToken, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt new file mode 100644 index 0000000000..27331943b8 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt @@ -0,0 +1,48 @@ +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(ApartmentShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(ApartmentPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt new file mode 100644 index 0000000000..0cf4719af1 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000000..cd67040a71 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt @@ -0,0 +1,41 @@ +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class ApartmentTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +internal data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +internal 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/data/SubmitFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt new file mode 100644 index 0000000000..19d2d7c4fb --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt @@ -0,0 +1,99 @@ +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentPriceIntentConfirmMutation +import octopus.ApartmentPriceIntentDataUpdateMutation +import octopus.fragment.ApartmentProductOfferFragment + +internal interface SubmitFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either +} + +internal class SubmitFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either { + return either { + val formData = buildMap { + put("street", street) + put("zipCode", zipCode) + put("livingSpace", livingSpace) + put("numberCoInsured", numberCoInsured) + } + + val updateResult = apolloClient + .mutation(ApartmentPriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(ApartmentPriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + ApartmentOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +internal fun ApartmentProductOfferFragment.toTierOffer(): ApartmentTierOffer { + return ApartmentTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt new file mode 100644 index 0000000000..8255554230 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt @@ -0,0 +1,46 @@ +package com.hedvig.android.feature.purchase.apartment.di + +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) } + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } + viewModel { params -> + SelectTierViewModel(params = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt new file mode 100644 index 0000000000..c03968519d --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt @@ -0,0 +1,88 @@ +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination + + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : ApartmentPurchaseDestination, Destination + + @Serializable + data object Failure : ApartmentPurchaseDestination, Destination +} + +@Serializable +internal data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +internal data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +internal data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +internal data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt new file mode 100644 index 0000000000..11f8293c71 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt @@ -0,0 +1,142 @@ +package com.hedvig.android.feature.purchase.apartment.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Failure +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Form +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.SelectTier +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Signing +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Success +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Summary +import com.hedvig.android.feature.purchase.apartment.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.apartmentPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination
{ backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: ApartmentFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + ApartmentFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination(Signing) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SigningViewModel = koinViewModel { + parametersOf(route.params) + } + SigningDestination( + viewModel = viewModel, + navigateToSuccess = { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + close = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) finishApp() + }, + ) + } + } + + navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.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 new file mode 100644 index 0000000000..a09ae6b6a2 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt @@ -0,0 +1,30 @@ +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/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt new file mode 100644 index 0000000000..c91931782e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -0,0 +1,276 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigStepper +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperSize.Medium +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperStyle.Labeled +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers + +@Composable +internal fun ApartmentFormDestination( + viewModel: ApartmentFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: ApartmentOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(ApartmentFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Hemförsäkring", + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + } + + else -> { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var numberCoInsured by remember { mutableIntStateOf(0) } + + ApartmentFormContent( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + livingSpaceError = uiState.livingSpaceError, + isSubmitting = uiState.isSubmitting, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onLivingSpaceChanged = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value + }, + onNumberCoInsuredChanged = { numberCoInsured = it }, + onSubmit = { + viewModel.emit( + ApartmentFormEvent.SubmitForm( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + ), + ) + }, + onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + } + } + } +} + +@Composable +private fun ApartmentFormContent( + street: String, + zipCode: String, + livingSpace: String, + numberCoInsured: Int, + streetError: String?, + zipCodeError: String?, + livingSpaceError: String?, + isSubmitting: Boolean, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onLivingSpaceChanged: (String) -> Unit, + onNumberCoInsuredChanged: (Int) -> 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 = 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 = livingSpace, + onValueChange = onLivingSpaceChanged, + labelText = "Boyta (kvm)", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = livingSpaceError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + enabled = !isSubmitting, + ) + HedvigStepper( + text = when (numberCoInsured) { + 0 -> "Bara du" + else -> "Du + $numberCoInsured" + }, + stepperSize = Medium, + stepperStyle = Labeled("Antal medf\u00f6rs\u00e4krade"), + onMinusClick = { onNumberCoInsuredChanged(numberCoInsured - 1) }, + onPlusClick = { onNumberCoInsuredChanged(numberCoInsured + 1) }, + isPlusEnabled = !isSubmitting && numberCoInsured < 5, + isMinusEnabled = !isSubmitting && numberCoInsured > 0, + ) + } + 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 PreviewApartmentFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + street = "", + zipCode = "", + livingSpace = "", + numberCoInsured = 0, + streetError = null, + zipCodeError = null, + livingSpaceError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + onRetry = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewApartmentFormFilled() { + 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 = {}, + onRetry = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewApartmentFormWithErrors() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + street = "", + zipCode = "123", + livingSpace = "", + numberCoInsured = 0, + streetError = "Ange en adress", + zipCodeError = "Ange ett giltigt postnummer (5 siffror)", + livingSpaceError = "Ange boyta i kvadratmeter", + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + 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 new file mode 100644 index 0000000000..02c5a6e1da --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt @@ -0,0 +1,175 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class ApartmentFormViewModel( + productName: String, + createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = ApartmentFormState(), + presenter = ApartmentFormPresenter(productName, createSessionAndPriceIntentUseCase, submitFormAndGetOffersUseCase), + ) + +internal sealed interface ApartmentFormEvent { + data class SubmitForm( + val street: String, + val zipCode: String, + val livingSpace: String, + val numberCoInsured: Int, + ) : ApartmentFormEvent + + data object ClearNavigation : ApartmentFormEvent + + data object Retry : ApartmentFormEvent +} + +internal data class ApartmentFormState( + val streetError: String? = null, + val zipCodeError: String? = null, + val livingSpaceError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: OffersNavigationData? = null, +) + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: ApartmentOffers, +) + +private class ApartmentFormPresenter( + private val productName: String, + private val createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + private val submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: ApartmentFormState): ApartmentFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: ApartmentFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is ApartmentFormEvent.SubmitForm -> { + val errors = validate(event.street, event.zipCode, event.livingSpace) + if (errors.hasErrors()) { + currentState = currentState.copy( + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + livingSpaceError = errors.livingSpaceError, + ) + } else { + currentState = currentState.copy( + streetError = null, + zipCodeError = null, + livingSpaceError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + ApartmentFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + ApartmentFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + street = submit.street, + zipCode = submit.zipCode, + livingSpace = submit.livingSpace.toInt(), + numberCoInsured = submit.numberCoInsured, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "Something went wrong", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val streetError: String?, + val zipCodeError: String?, + val livingSpaceError: String?, +) { + fun hasErrors(): Boolean = streetError != null || zipCodeError != null || livingSpaceError != null +} + +private fun validate(street: String, zipCode: String, livingSpace: String): ValidationErrors { + return ValidationErrors( + streetError = if (street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + else -> null + }, + livingSpaceError = when { + livingSpace.isBlank() -> "Ange boyta" + livingSpace.toIntOrNull() == null -> "Ange ett giltigt tal" + livingSpace.toInt() <= 0 -> "Boytan måste vara större än 0" + else -> null + }, + ) +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt new file mode 100644 index 0000000000..894904d634 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -0,0 +1,290 @@ +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 new file mode 100644 index 0000000000..bebd2f7edf --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt @@ -0,0 +1,142 @@ +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 new file mode 100644 index 0000000000..490eff39a7 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000000..49e98d1094 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000000..8edeb135ab --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000000..8cc0899aac --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000000..02348fb2af --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt @@ -0,0 +1,102 @@ +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 +} diff --git a/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt b/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt new file mode 100644 index 0000000000..e6540ac8ea --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt @@ -0,0 +1,80 @@ +package data + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.prop +import com.apollographql.apollo.annotations.ApolloExperimental +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.testing.registerTestResponse +import com.hedvig.android.apollo.octopus.test.OctopusFakeResolver +import com.hedvig.android.apollo.test.TestApolloClientRule +import com.hedvig.android.apollo.test.TestNetworkTransportType +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.test.isLeft +import com.hedvig.android.core.common.test.isRight +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.logger.TestLogcatLoggingRule +import kotlinx.coroutines.test.runTest +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode +import octopus.type.buildPriceIntent +import octopus.type.buildShopSession +import org.junit.Rule +import org.junit.Test + +class CreateSessionAndPriceIntentUseCaseTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + @get:Rule + val testApolloClientRule = TestApolloClientRule(TestNetworkTransportType.MAP) + + @OptIn(ApolloExperimental::class) + @Test + fun `successful session and price intent creation returns both ids`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = ApartmentShopSessionCreateMutation.Data(OctopusFakeResolver) { + shopSessionCreate = buildShopSession { + id = "session-123" + } + }, + ) + registerTestResponse( + operation = ApartmentPriceIntentCreateMutation( + shopSessionId = "session-123", + productName = "SE_APARTMENT_RENT", + ), + data = ApartmentPriceIntentCreateMutation.Data(OctopusFakeResolver) { + priceIntentCreate = buildPriceIntent { + id = "intent-456" + } + }, + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isRight().isEqualTo(SessionAndIntent("session-123", "intent-456")) + } + + @OptIn(ApolloExperimental::class) + @Test + fun `network error on session creation returns ErrorMessage`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = null, + errors = listOf(Error.Builder(message = "Network error").build()), + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isLeft().prop(ErrorMessage::message).isNull() + } +} diff --git a/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md b/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md new file mode 100644 index 0000000000..64f718ee06 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md @@ -0,0 +1,2347 @@ +# In-App Apartment Purchase Flow — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a native Compose purchase flow for SE_APARTMENT_RENT and SE_APARTMENT_BRF, replacing the current link-out to hedvig.com for apartment cross-sells. + +**Architecture:** New feature module `feature-purchase-apartment` using the ShopSession + PriceIntent GraphQL API (same as racoon web). Follows MoleculeViewModel + MoleculePresenter pattern. State threaded via serializable navigation arguments. Entry point via cross-sell click on insurance tab. + +**Tech Stack:** Jetpack Compose, Apollo GraphQL, Molecule, Koin, Arrow Either, kotlinx.serialization, Navigation Compose (type-safe) + +**Spec:** `docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md` + +--- + +## File Map + +### New files (feature module) + +| File | Responsibility | +|------|---------------| +| `feature-purchase-apartment/build.gradle.kts` | Module build config | +| `.../navigation/ApartmentPurchaseDestination.kt` | All destination types + serializable parameter classes | +| `.../navigation/ApartmentPurchaseNavGraph.kt` | Navigation graph wiring | +| `.../data/PurchaseApartmentModels.kt` | Domain models: `TierOffer`, `ApartmentFormData` | +| `.../data/CreateSessionAndPriceIntentUseCase.kt` | Creates ShopSession + PriceIntent | +| `.../data/SubmitFormAndGetOffersUseCase.kt` | Calls priceIntentDataUpdate + priceIntentConfirm | +| `.../data/AddToCartAndStartSignUseCase.kt` | Adds to cart + starts signing | +| `.../data/PollSigningStatusUseCase.kt` | Polls shopSessionSigning | +| `.../ui/form/ApartmentFormViewModel.kt` | Form presenter with validation | +| `.../ui/form/ApartmentFormDestination.kt` | Form screen composable | +| `.../ui/offer/SelectTierViewModel.kt` | Tier selection presenter | +| `.../ui/offer/SelectTierDestination.kt` | Tier selection screen composable | +| `.../ui/summary/PurchaseSummaryViewModel.kt` | Summary + submit presenter | +| `.../ui/summary/PurchaseSummaryDestination.kt` | Summary screen composable | +| `.../ui/sign/SigningViewModel.kt` | BankID signing presenter | +| `.../ui/sign/SigningDestination.kt` | Signing screen composable | +| `.../ui/success/PurchaseSuccessDestination.kt` | Success screen (stateless) | +| `.../ui/failure/PurchaseFailureDestination.kt` | Failure screen (stateless) | +| `.../di/ApartmentPurchaseModule.kt` | Koin DI module | +| `src/main/graphql/ShopSessionCreateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentCreateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentDataUpdateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentConfirmMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionStartSignMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionSigningQuery.graphql` | GraphQL | +| `src/main/graphql/ProductOfferFragment.graphql` | Shared fragment | +| `src/main/graphql/MoneyFragment.graphql` | Shared fragment | + +### Modified files (integration) + +| File | Change | +|------|--------| +| `app/app/src/main/kotlin/.../navigation/HedvigNavHost.kt` | Register `apartmentPurchaseNavGraph`, add `onNavigateToApartmentPurchase` to `insuranceGraph` call | +| `app/app/src/main/kotlin/.../di/ApplicationModule.kt` | Add `apartmentPurchaseModule` to includes | +| `app/feature/feature-insurances/src/main/kotlin/.../navigation/InsuranceGraph.kt` | Add `onNavigateToApartmentPurchase` parameter, wire cross-sell click routing | +| `app/feature/feature-insurances/src/main/kotlin/.../ui/InsurancePresenter.kt` | Pass product type info through cross-sell click | +| `app/data/data-cross-sell-after-flow/.../CrossSellAfterFlowRepository.kt` | Add `Purchase` to `CrossSellInfoType` | + +--- + +## Task 1: Module scaffolding and GraphQL operations + +**Files:** +- Create: `app/feature/feature-purchase-apartment/build.gradle.kts` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/MoneyFragment.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt` + +- [ ] **Step 1: Create the module directory and build.gradle.kts** + +```bash +mkdir -p app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment +mkdir -p app/feature/feature-purchase-apartment/src/main/graphql +``` + +`app/feature/feature-purchase-apartment/build.gradle.kts`: +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.languageCore) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} +``` + +- [ ] **Step 2: Create the GraphQL fragment files** + +`src/main/graphql/MoneyFragment.graphql`: +```graphql +fragment MoneyFragment on Money { + amount + currencyCode +} +``` + +`src/main/graphql/ProductOfferFragment.graphql`: +```graphql +fragment ApartmentProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} +``` + +- [ ] **Step 3: Create the mutation and query GraphQL files** + +`src/main/graphql/ShopSessionCreateMutation.graphql`: +```graphql +mutation ApartmentShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} +``` + +`src/main/graphql/PriceIntentCreateMutation.graphql`: +```graphql +mutation ApartmentPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} +``` + +`src/main/graphql/PriceIntentDataUpdateMutation.graphql`: +```graphql +mutation ApartmentPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} +``` + +`src/main/graphql/PriceIntentConfirmMutation.graphql`: +```graphql +mutation ApartmentPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...ApartmentProductOfferFragment + } + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionCartEntriesAddMutation.graphql`: +```graphql +mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionStartSignMutation.graphql`: +```graphql +mutation ApartmentStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionSigningQuery.graphql`: +```graphql +query ApartmentShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} +``` + +- [ ] **Step 4: Create the destination definitions** + +`app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination + + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : ApartmentPurchaseDestination, Destination + + @Serializable + data object Failure : ApartmentPurchaseDestination, Destination +} + +@Serializable +internal data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +internal data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +internal data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +internal data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) +``` + +- [ ] **Step 5: Verify the module builds with Apollo codegen** + +```bash +./gradlew :feature-purchase-apartment:generateApolloSources +``` + +Expected: BUILD SUCCESSFUL. Apollo generates Kotlin types for all 7 GraphQL operations and 2 fragments. + +- [ ] **Step 6: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: scaffold feature-purchase-apartment module with GraphQL operations" +``` + +--- + +## Task 2: Domain models and use cases + +**Files:** +- Create: `.../data/PurchaseApartmentModels.kt` +- Create: `.../data/CreateSessionAndPriceIntentUseCase.kt` +- Create: `.../data/SubmitFormAndGetOffersUseCase.kt` +- Create: `.../data/AddToCartAndStartSignUseCase.kt` +- Create: `.../data/PollSigningStatusUseCase.kt` +- Test: `src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt` +- Test: `src/test/kotlin/data/SubmitFormAndGetOffersUseCaseTest.kt` +- Test: `src/test/kotlin/data/AddToCartAndStartSignUseCaseTest.kt` + +- [ ] **Step 1: Create domain models** + +`.../data/PurchaseApartmentModels.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class ApartmentTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +internal data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +internal enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} +``` + +- [ ] **Step 2: Create CreateSessionAndPriceIntentUseCase** + +`.../data/CreateSessionAndPriceIntentUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(ApartmentShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(ApartmentPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} +``` + +- [ ] **Step 3: Create SubmitFormAndGetOffersUseCase** + +`.../data/SubmitFormAndGetOffersUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentPriceIntentConfirmMutation +import octopus.ApartmentPriceIntentDataUpdateMutation +import octopus.fragment.ApartmentProductOfferFragment + +internal interface SubmitFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either +} + +internal class SubmitFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either { + return either { + val formData = buildMap { + put("street", street) + put("zipCode", zipCode) + put("livingSpace", livingSpace) + put("numberCoInsured", numberCoInsured) + } + + val updateResult = apolloClient + .mutation(ApartmentPriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(ApartmentPriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + ApartmentOffers( + productDisplayName = offers.first().apartmentProductOfferFragment.variant.displayName, + offers = offers.map { it.apartmentProductOfferFragment.toTierOffer() }, + ) + } + } +} + +internal fun ApartmentProductOfferFragment.toTierOffer(): ApartmentTierOffer { + val cost = this.cost + return ApartmentTierOffer( + offerId = this.id, + tierDisplayName = this.variant.displayNameTier ?: this.variant.displayName, + tierDescription = this.variant.tierDescription ?: "", + grossPrice = UiMoney(cost.gross.moneyFragment.amount, cost.gross.moneyFragment.currencyCode), + netPrice = UiMoney(cost.net.moneyFragment.amount, cost.net.moneyFragment.currencyCode), + usps = this.usps, + exposureDisplayName = this.exposure.displayNameShort, + deductibleDisplayName = this.deductible?.displayName, + hasDiscount = cost.net.moneyFragment.amount < cost.gross.moneyFragment.amount, + ) +} +``` + +- [ ] **Step 4: Create AddToCartAndStartSignUseCase** + +`.../data/AddToCartAndStartSignUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentCartEntriesAddMutation +import octopus.ApartmentStartSignMutation + +internal interface AddToCartAndStartSignUseCase { + suspend fun invoke(shopSessionId: String, offerId: String): Either +} + +internal class AddToCartAndStartSignUseCaseImpl( + private val apolloClient: ApolloClient, +) : AddToCartAndStartSignUseCase { + override suspend fun invoke(shopSessionId: String, offerId: String): Either { + return either { + val cartResult = apolloClient + .mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to add to cart: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCartEntriesAdd }, + ) + + if (cartResult?.userError != null) { + raise(ErrorMessage(cartResult.userError?.message)) + } + + val signResult = apolloClient + .mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to start signing: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionStartSign }, + ) + + if (signResult.userError != null) { + raise(ErrorMessage(signResult.userError?.message)) + } + + val signing = signResult.signing ?: run { + logcat(LogPriority.ERROR) { "No signing session returned" } + raise(ErrorMessage()) + } + + val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run { + logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" } + raise(ErrorMessage()) + } + + SigningStart( + signingId = signing.id, + autoStartToken = autoStartToken, + ) + } + } +} +``` + +- [ ] **Step 5: Create PollSigningStatusUseCase** + +`.../data/PollSigningStatusUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.ApartmentShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +internal interface PollSigningStatusUseCase { + suspend fun invoke(signingId: String): Either +} + +internal class PollSigningStatusUseCaseImpl( + private val apolloClient: ApolloClient, +) : PollSigningStatusUseCase { + override suspend fun invoke(signingId: String): Either { + return either { + apolloClient + .query(ApartmentShopSessionSigningQuery(signingId = signingId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + when (result.shopSessionSigning.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__ -> SigningStatus.PENDING + } + }, + ) + } + } +} +``` + +- [ ] **Step 6: Write tests for CreateSessionAndPriceIntentUseCase** + +`src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt`: +```kotlin +package data + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.prop +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.annotations.ApolloExperimental +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.testing.registerTestResponse +import com.hedvig.android.apollo.octopus.test.OctopusFakeResolver +import com.hedvig.android.apollo.test.TestApolloClientRule +import com.hedvig.android.apollo.test.TestNetworkTransportType +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.test.isLeft +import com.hedvig.android.core.common.test.isRight +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.logger.TestLogcatLoggingRule +import kotlinx.coroutines.test.runTest +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode +import org.junit.Rule +import org.junit.Test + +class CreateSessionAndPriceIntentUseCaseTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + @get:Rule + val testApolloClientRule = TestApolloClientRule(TestNetworkTransportType.MAP) + + @OptIn(ApolloExperimental::class) + @Test + fun `successful session and price intent creation returns both ids`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = ApartmentShopSessionCreateMutation.Data(OctopusFakeResolver) { + shopSessionCreate = buildShopSession { + id = "session-123" + } + }, + ) + registerTestResponse( + operation = ApartmentPriceIntentCreateMutation(shopSessionId = "session-123", productName = "SE_APARTMENT_RENT"), + data = ApartmentPriceIntentCreateMutation.Data(OctopusFakeResolver) { + priceIntentCreate = buildPriceIntent { + id = "intent-456" + } + }, + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isRight().isEqualTo(SessionAndIntent("session-123", "intent-456")) + } + + @OptIn(ApolloExperimental::class) + @Test + fun `network error on session creation returns ErrorMessage`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = null, + errors = listOf(Error.Builder(message = "Network error").build()), + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isLeft().prop(ErrorMessage::message).isNull() + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +./gradlew :feature-purchase-apartment:test +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add domain models and use cases for apartment purchase" +``` + +--- + +## Task 3: Form screen (presenter + UI) + +**Files:** +- Create: `.../ui/form/ApartmentFormViewModel.kt` +- Create: `.../ui/form/ApartmentFormDestination.kt` + +- [ ] **Step 1: Create ApartmentFormViewModel with presenter** + +`.../ui/form/ApartmentFormViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class ApartmentFormViewModel( + productName: String, + createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = ApartmentFormState.Idle(), + presenter = ApartmentFormPresenter(productName, createSessionAndPriceIntentUseCase, submitFormAndGetOffersUseCase), + ) + +internal class ApartmentFormPresenter( + private val productName: String, + private val createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + private val submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: ApartmentFormState): ApartmentFormState { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var numberCoInsured by remember { mutableIntStateOf(0) } + + var streetError by remember { mutableStateOf(null) } + var zipCodeError by remember { mutableStateOf(null) } + var livingSpaceError by remember { mutableStateOf(null) } + + var submitIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(false) } + var submitError by remember { mutableStateOf(null) } + var offersToNavigate by remember { mutableStateOf(null) } + + var sessionAndIntent by remember { mutableStateOf(null) } + var sessionCreateIteration by remember { mutableIntStateOf(0) } + + CollectEvents { event -> + when (event) { + is ApartmentFormEvent.UpdateStreet -> { + street = event.value + streetError = null + } + is ApartmentFormEvent.UpdateZipCode -> { + zipCode = event.value + zipCodeError = null + } + is ApartmentFormEvent.UpdateLivingSpace -> { + livingSpace = event.value + livingSpaceError = null + } + is ApartmentFormEvent.UpdateNumberCoInsured -> { + numberCoInsured = event.value + } + ApartmentFormEvent.Submit -> { + val validStreet = street.isNotBlank() + val validZipCode = zipCode.length == 5 && zipCode.all { it.isDigit() } + val validLivingSpace = livingSpace.toIntOrNull()?.let { it > 0 } ?: false + + streetError = if (!validStreet) "Ange en adress" else null + zipCodeError = if (!validZipCode) "Ange ett giltigt postnummer (5 siffror)" else null + livingSpaceError = if (!validLivingSpace) "Ange boyta i kvadratmeter" else null + + if (validStreet && validZipCode && validLivingSpace) { + submitIteration++ + } + } + ApartmentFormEvent.ClearNavigation -> { + offersToNavigate = null + } + ApartmentFormEvent.Retry -> { + submitError = null + sessionCreateIteration++ + } + } + } + + LaunchedEffect(sessionCreateIteration) { + if (sessionAndIntent != null) return@LaunchedEffect + createSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { submitError = it }, + ifRight = { sessionAndIntent = it }, + ) + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + isSubmitting = true + submitError = null + + submitFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + street = street, + zipCode = zipCode, + livingSpace = livingSpace.toInt(), + numberCoInsured = numberCoInsured, + ).fold( + ifLeft = { + isSubmitting = false + submitError = it + }, + ifRight = { offers -> + isSubmitting = false + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ) + }, + ) + } + + return ApartmentFormState( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + streetError = streetError, + zipCodeError = zipCodeError, + livingSpaceError = livingSpaceError, + isSubmitting = isSubmitting, + submitError = submitError, + offersToNavigate = offersToNavigate, + ) + } +} + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: ApartmentOffers, +) + +internal sealed interface ApartmentFormEvent { + data class UpdateStreet(val value: String) : ApartmentFormEvent + data class UpdateZipCode(val value: String) : ApartmentFormEvent + data class UpdateLivingSpace(val value: String) : ApartmentFormEvent + data class UpdateNumberCoInsured(val value: Int) : ApartmentFormEvent + data object Submit : ApartmentFormEvent + data object ClearNavigation : ApartmentFormEvent + data object Retry : ApartmentFormEvent +} + +internal data class ApartmentFormState( + val street: String = "", + val zipCode: String = "", + val livingSpace: String = "", + val numberCoInsured: Int = 0, + val streetError: String? = null, + val zipCodeError: String? = null, + val livingSpaceError: String? = null, + val isSubmitting: Boolean = false, + val submitError: ErrorMessage? = null, + val offersToNavigate: OffersNavigationData? = null, +) { + constructor() : this("", "", "", 0) +} +``` + +- [ ] **Step 2: Create ApartmentFormDestination composable** + +`.../ui/form/ApartmentFormDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults.ErrorState +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers + +@Composable +internal fun ApartmentFormDestination( + viewModel: ApartmentFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: ApartmentOffers) -> Unit, +) { + val state = viewModel.state + LaunchedEffect(state.offersToNavigate) { + val nav = state.offersToNavigate ?: return@LaunchedEffect + viewModel.emit(ApartmentFormEvent.ClearNavigation) + onOffersReceived(nav.shopSessionId, nav.offers) + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Hemförsäkring", + ) { + if (state.isSubmitting) { + HedvigFullScreenCenterAlignedProgress() + return@HedvigScaffold + } + + if (state.submitError != null) { + HedvigErrorSection( + onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + return@HedvigScaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.street, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateStreet(it)) }, + labelText = "Gatuadress", + errorState = state.streetError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.zipCode, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateZipCode(it)) }, + labelText = "Postnummer", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + errorState = state.zipCodeError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.livingSpace, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateLivingSpace(it)) }, + labelText = "Boyta (kvm)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + errorState = state.livingSpaceError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.numberCoInsured.toString(), + onValueChange = { newValue -> + newValue.toIntOrNull()?.let { viewModel.emit(ApartmentFormEvent.UpdateNumberCoInsured(it)) } + }, + labelText = "Antal medförsäkrade", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(32.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Beräkna pris", + onClick = { viewModel.emit(ApartmentFormEvent.Submit) }, + enabled = !state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add apartment purchase form screen with validation" +``` + +--- + +## Task 4: Tier selection screen + +**Files:** +- Create: `.../ui/offer/SelectTierViewModel.kt` +- Create: `.../ui/offer/SelectTierDestination.kt` + +- [ ] **Step 1: Create SelectTierViewModel** + +`.../ui/offer/SelectTierViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class SelectTierViewModel( + selectTierParameters: SelectTierParameters, +) : MoleculeViewModel( + initialState = SelectTierState( + offers = selectTierParameters.offers, + selectedOfferId = selectTierParameters.offers.firstOrNull { it.tierDisplayName.contains("Standard", ignoreCase = true) }?.offerId + ?: selectTierParameters.offers.firstOrNull()?.offerId, + shopSessionId = selectTierParameters.shopSessionId, + productDisplayName = selectTierParameters.productDisplayName, + ), + presenter = SelectTierPresenter(selectTierParameters), + ) + +internal class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierState): SelectTierState { + var selectedOfferId by remember { mutableStateOf(lastState.selectedOfferId) } + var summaryToNavigate by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is SelectTierEvent.SelectOffer -> { + selectedOfferId = event.offerId + } + SelectTierEvent.Continue -> { + val selectedOffer = params.offers.first { it.offerId == selectedOfferId } + summaryToNavigate = SummaryParameters( + shopSessionId = params.shopSessionId, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } + } + } + + return SelectTierState( + offers = params.offers, + selectedOfferId = selectedOfferId, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +internal sealed interface SelectTierEvent { + data class SelectOffer(val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent +} + +internal data class SelectTierState( + val offers: List, + val selectedOfferId: String?, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters? = null, +) +``` + +- [ ] **Step 2: Create SelectTierDestination composable** + +`.../ui/offer/SelectTierDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData + +@Composable +internal fun SelectTierDestination( + viewModel: SelectTierViewModel, + navigateUp: () -> Unit, + onContinueToSummary: (SummaryParameters) -> Unit, +) { + val state = viewModel.state + + LaunchedEffect(state.summaryToNavigate) { + val params = state.summaryToNavigate ?: return@LaunchedEffect + viewModel.emit(SelectTierEvent.ClearNavigation) + onContinueToSummary(params) + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = state.productDisplayName, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigText( + text = "Anpassa din försäkring", + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = "Välj en försäkringsnivå som passar dig", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + + Spacer(Modifier.height(24.dp)) + + state.offers.forEach { offer -> + TierCard( + offer = offer, + isSelected = offer.offerId == state.selectedOfferId, + onSelect = { viewModel.emit(SelectTierEvent.SelectOffer(offer.offerId)) }, + ) + Spacer(Modifier.height(12.dp)) + } + + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Fortsätt", + onClick = { viewModel.emit(SelectTierEvent.Continue) }, + enabled = state.selectedOfferId != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun TierCard( + offer: TierOfferData, + isSelected: Boolean, + onSelect: () -> Unit, +) { + val backgroundColor = if (isSelected) { + HedvigTheme.colorScheme.surfacePrimary + } else { + HedvigTheme.colorScheme.surfaceSecondary + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .clickable(onClick = onSelect) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + val priceText = if (offer.hasDiscount) { + "${offer.netAmount.toInt()} kr/mån" + } else { + "${offer.grossAmount.toInt()} kr/mån" + } + HedvigText( + text = priceText, + style = HedvigTheme.typography.labelLarge, + ) + } + + Spacer(Modifier.height(4.dp)) + HedvigText( + text = offer.tierDescription, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + + AnimatedVisibility(visible = isSelected && offer.usps.isNotEmpty()) { + Column(modifier = Modifier.padding(top = 12.dp)) { + offer.usps.forEach { usp -> + HedvigText( + text = "✓ $usp", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + if (isSelected) { + HedvigButton( + text = "Se vad som ingår", + onClick = { /* v1: no coverage comparison */ }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + HedvigButton( + text = "Välj ${offer.tierDisplayName}", + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add tier selection screen for apartment purchase" +``` + +--- + +## Task 5: Summary and signing screens + +**Files:** +- Create: `.../ui/summary/PurchaseSummaryViewModel.kt` +- Create: `.../ui/summary/PurchaseSummaryDestination.kt` +- Create: `.../ui/sign/SigningViewModel.kt` +- Create: `.../ui/sign/SigningDestination.kt` +- Create: `.../ui/success/PurchaseSuccessDestination.kt` +- Create: `.../ui/failure/PurchaseFailureDestination.kt` + +- [ ] **Step 1: Create PurchaseSummaryViewModel** + +`.../ui/summary/PurchaseSummaryViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.summary + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.SigningStart +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryState.Content(summaryParameters), + presenter = PurchaseSummaryPresenter(summaryParameters, addToCartAndStartSignUseCase), + ) + +internal class PurchaseSummaryPresenter( + private val params: SummaryParameters, + private val addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PurchaseSummaryState, + ): PurchaseSummaryState { + var submitIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(false) } + var signingToNavigate by remember { mutableStateOf(null) } + var navigateToFailure by remember { mutableStateOf(false) } + + CollectEvents { event -> + when (event) { + PurchaseSummaryEvent.Confirm -> submitIteration++ + PurchaseSummaryEvent.ClearNavigation -> { + signingToNavigate = null + navigateToFailure = false + } + } + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + isSubmitting = true + + addToCartAndStartSignUseCase.invoke( + shopSessionId = params.shopSessionId, + offerId = params.selectedOffer.offerId, + ).fold( + ifLeft = { + isSubmitting = false + navigateToFailure = true + }, + ifRight = { signingStart -> + isSubmitting = false + signingToNavigate = SigningParameters( + signingId = signingStart.signingId, + autoStartToken = signingStart.autoStartToken, + startDate = params.selectedOffer.exposureDisplayName, + ) + }, + ) + } + + return PurchaseSummaryState.Content( + params = params, + isSubmitting = isSubmitting, + signingToNavigate = signingToNavigate, + navigateToFailure = navigateToFailure, + ) + } +} + +internal sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent +} + +internal sealed interface PurchaseSummaryState { + data class Content( + val params: SummaryParameters, + val isSubmitting: Boolean = false, + val signingToNavigate: SigningParameters? = null, + val navigateToFailure: Boolean = false, + ) : PurchaseSummaryState +} +``` + +- [ ] **Step 2: Create PurchaseSummaryDestination** + +`.../ui/summary/PurchaseSummaryDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.summary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters + +@Composable +internal fun PurchaseSummaryDestination( + viewModel: PurchaseSummaryViewModel, + navigateUp: () -> Unit, + onNavigateToSigning: (SigningParameters) -> Unit, + onNavigateToFailure: () -> Unit, +) { + val state = viewModel.state as? PurchaseSummaryState.Content ?: return + + LaunchedEffect(state.signingToNavigate) { + val params = state.signingToNavigate ?: return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + onNavigateToSigning(params) + } + + LaunchedEffect(state.navigateToFailure) { + if (!state.navigateToFailure) return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + onNavigateToFailure() + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Sammanfattning", + ) { + if (state.isSubmitting) { + HedvigFullScreenCenterAlignedProgress() + return@HedvigScaffold + } + + val offer = state.params.selectedOffer + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + HedvigText( + text = state.params.productDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + HedvigText( + text = offer.exposureDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + HedvigText(text = "Ditt pris", modifier = Modifier.weight(1f)) + if (offer.hasDiscount) { + HedvigText( + text = "${offer.netAmount.toInt()} kr/mån", + style = HedvigTheme.typography.bodyLarge, + ) + } else { + HedvigText( + text = "${offer.grossAmount.toInt()} kr/mån", + style = HedvigTheme.typography.bodyLarge, + ) + } + } + } + } + + Spacer(Modifier.height(32.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Signera med BankID", + onClick = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, + enabled = !state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} +``` + +- [ ] **Step 3: Create SigningViewModel** + +`.../ui/sign/SigningViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.SigningStatus +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import kotlinx.coroutines.delay + +internal class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningState.Polling(signingParameters.autoStartToken, signingParameters.startDate), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), + ) + +internal class SigningPresenter( + private val params: SigningParameters, + private val pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SigningState): SigningState { + var status by remember { mutableStateOf(null) } + var bankIdOpened by remember { mutableStateOf(false) } + + CollectEvents { event -> + when (event) { + SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.ClearNavigation -> status = null + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(2000) + pollSigningStatusUseCase.invoke(params.signingId).fold( + ifLeft = { status = SigningStatus.FAILED }, + ifRight = { polledStatus -> + if (polledStatus != SigningStatus.PENDING) { + status = polledStatus + return@LaunchedEffect + } + }, + ) + } + } + + return when (status) { + SigningStatus.SIGNED -> SigningState.Success(params.startDate) + SigningStatus.FAILED -> SigningState.Failed + else -> SigningState.Polling( + autoStartToken = params.autoStartToken, + startDate = params.startDate, + bankIdOpened = bankIdOpened, + ) + } + } +} + +internal sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent +} + +internal sealed interface SigningState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val bankIdOpened: Boolean = false, + ) : SigningState + + data class Success(val startDate: String?) : SigningState + data object Failed : SigningState +} +``` + +- [ ] **Step 4: Create SigningDestination** + +`.../ui/sign/SigningDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme + +@Composable +internal fun SigningDestination( + viewModel: SigningViewModel, + onSuccess: (startDate: String?) -> Unit, + onFailure: () -> Unit, +) { + val state = viewModel.state + val context = LocalContext.current + + LaunchedEffect(state) { + if (state is SigningState.Polling && !state.bankIdOpened) { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + } + } + + LaunchedEffect(state) { + when (state) { + is SigningState.Success -> { + viewModel.emit(SigningEvent.ClearNavigation) + onSuccess(state.startDate) + } + is SigningState.Failed -> { + viewModel.emit(SigningEvent.ClearNavigation) + onFailure() + } + is SigningState.Polling -> {} + } + } + + HedvigScaffold(topAppBarText = "") { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HedvigFullScreenCenterAlignedProgress() + HedvigText( + text = "Väntar på BankID...", + style = HedvigTheme.typography.bodyLarge, + ) + } + } +} +``` + +- [ ] **Step 5: Create success and failure destinations** + +`.../ui/success/PurchaseSuccessDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.success + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme + +@Composable +internal fun PurchaseSuccessDestination( + startDate: String?, + onClose: () -> Unit, +) { + HedvigScaffold(topAppBarText = "") { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HedvigText( + text = "Din försäkring är klar!", + style = HedvigTheme.typography.headlineMedium, + ) + if (startDate != null) { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Startdatum: $startDate", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + Spacer(Modifier.height(32.dp)) + HedvigButton( + text = "Stäng", + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} +``` + +`.../ui/failure/PurchaseFailureDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.failure + +import androidx.compose.runtime.Composable +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigScaffold + +@Composable +internal fun PurchaseFailureDestination( + onRetry: () -> Unit, + onClose: () -> Unit, +) { + HedvigScaffold( + navigateUp = onClose, + topAppBarText = "", + ) { + HedvigErrorSection(onButtonClick = onRetry) + } +} +``` + +- [ ] **Step 6: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add summary, signing, success, and failure screens" +``` + +--- + +## Task 6: Navigation graph and DI module + +**Files:** +- Create: `.../navigation/ApartmentPurchaseNavGraph.kt` +- Create: `.../di/ApartmentPurchaseModule.kt` + +- [ ] **Step 1: Create ApartmentPurchaseNavGraph** + +`.../navigation/ApartmentPurchaseNavGraph.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Failure +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Form +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.SelectTier +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Signing +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Success +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Summary +import com.hedvig.android.feature.purchase.apartment.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.apartmentPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getBackStackEntry(ApartmentPurchaseGraphDestination::class.java.name) + .toRoute() + + val viewModel: ApartmentFormViewModel = koinViewModel { parametersOf(graphRoute.productName) } + + ApartmentFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = dropUnlessResumed { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(it.toRoute().params) + } + + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = dropUnlessResumed { params -> + navController.navigate(Summary(params)) + }, + ) + } + + navdestination(Summary) { + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(it.toRoute().params) + } + + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onNavigateToSigning = dropUnlessResumed { params -> + navController.navigate(Signing(params)) + }, + onNavigateToFailure = dropUnlessResumed { + navController.navigate(Failure) + }, + ) + } + + navdestination(Signing) { + val route = it.toRoute() + val viewModel: SigningViewModel = koinViewModel { parametersOf(route.params) } + + SigningDestination( + viewModel = viewModel, + onSuccess = dropUnlessResumed { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + onFailure = dropUnlessResumed { + navController.navigate(Failure) + }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + onClose = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) { + finishApp() + } + }, + ) + } + } + + navdestination { + val route = it.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + onClose = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) { + finishApp() + } + }, + ) + } +} +``` + +- [ ] **Step 2: Create ApartmentPurchaseModule** + +`.../di/ApartmentPurchaseModule.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.di + +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { + CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) + } + single { + SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) + } + single { + AddToCartAndStartSignUseCaseImpl(apolloClient = get()) + } + single { + PollSigningStatusUseCaseImpl(apolloClient = get()) + } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } + viewModel { params -> + SelectTierViewModel(selectTierParameters = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add navigation graph and DI module for apartment purchase" +``` + +--- + +## Task 7: Integration into the main app + +**Files:** +- Modify: `app/data/data-cross-sell-after-flow/.../CrossSellAfterFlowRepository.kt` +- Modify: `app/feature/feature-insurances/.../navigation/InsuranceGraph.kt` +- Modify: `app/app/src/main/kotlin/.../navigation/HedvigNavHost.kt` +- Modify: `app/app/src/main/kotlin/.../di/ApplicationModule.kt` + +- [ ] **Step 1: Add `Purchase` to CrossSellInfoType** + +In `app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt`, add a new variant to the `CrossSellInfoType` sealed class: + +```kotlin +data object Purchase : CrossSellInfoType() +``` + +Add it alongside the existing variants (`ClosedClaim`, `ChangeTier`, `Addon`, `EditCoInsured`, `MovingFlow`). + +- [ ] **Step 2: Add apartment purchase navigation parameter to InsuranceGraph** + +In `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt`, add a new parameter to the `insuranceGraph` function: + +```kotlin +fun NavGraphBuilder.insuranceGraph( + // ... existing parameters ... + onNavigateToApartmentPurchase: (productName: String) -> Unit, +) +``` + +In the `InsurancesDestination.Insurances` navdestination block, change the cross-sell click handler from: + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> openUrl(url) }, +``` + +to route apartment products in-app and everything else to the web: + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> + // TODO: Once the backend provides product type in cross-sell data, + // route SE_APARTMENT_RENT and SE_APARTMENT_BRF to in-app purchase. + // For now, all cross-sells open the web URL. + openUrl(url) +}, +``` + +Note: The actual routing requires the cross-sell to carry a `productName` field. This likely needs a backend change to the `CrossSellV2` type or a way to derive the product type from the cross-sell data. For the initial integration, the in-app flow can be triggered from a deep link or a direct navigation call while the cross-sell routing is resolved with the backend team. + +- [ ] **Step 3: Register the nav graph in HedvigNavHost** + +In `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt`: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph +``` + +Add to the `insuranceGraph(...)` call: +```kotlin +onNavigateToApartmentPurchase = { productName -> + navController.navigate(ApartmentPurchaseGraphDestination(productName)) +}, +``` + +Add as a sibling graph in the NavHost (alongside `addonPurchaseNavGraph`, `changeTierGraph`, etc.): +```kotlin +apartmentPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = get(), +) +``` + +- [ ] **Step 4: Register DI module in ApplicationModule** + +In `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt`: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule +``` + +Add to the `includes(listOf(...))` call: +```kotlin +apartmentPurchaseModule, +``` + +- [ ] **Step 5: Verify the full app builds** + +```bash +./gradlew :app:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. The module is auto-discovered by `settings.gradle.kts`, the DI module is included, and the nav graph is registered. + +- [ ] **Step 6: Commit** + +```bash +git add app/data/data-cross-sell-after-flow/ app/feature/feature-insurances/ app/app/ +git commit -m "feat: integrate apartment purchase flow into main app navigation" +``` + +--- + +## Task 8: Run ktlint and fix formatting + +- [ ] **Step 1: Run ktlint check on the new module** + +```bash +./gradlew :feature-purchase-apartment:ktlintCheck +``` + +- [ ] **Step 2: Fix any formatting issues** + +```bash +./gradlew :feature-purchase-apartment:ktlintFormat +``` + +- [ ] **Step 3: Run all tests** + +```bash +./gradlew :feature-purchase-apartment:test +``` + +Expected: All tests pass. + +- [ ] **Step 4: Run the full app build** + +```bash +./gradlew :app:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit any formatting fixes** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "chore: fix ktlint formatting in apartment purchase module" +``` + +--- + +## Notes for Implementer + +### GraphQL `PricingFormData` type +The `PricingFormData` scalar type in the schema is a `Map`. When calling `ApartmentPriceIntentDataUpdateMutation`, you pass a `Map` with keys: `"street"`, `"zipCode"`, `"livingSpace"`, `"numberCoInsured"`. Apollo's custom scalar adapter handles the serialization. Check how racoon sends this data in `/Users/hugolinder/repos/racoon/apps/store/src/graphql/PriceIntentDataUpdate.graphql` for reference. + +### BankID URI pattern +The BankID app is launched via `https://app.bankid.com/?autostarttoken={token}&redirect=null`. See `app/feature/feature-login/src/main/kotlin/com/hedvig/android/feature/login/swedishlogin/BankIdState.kt` for the existing implementation including app detection and QR code fallback. For v1, the simple URI launch is sufficient. + +### Cross-sell routing +The current cross-sell click handler receives only a `storeUrl: String`. To route apartment products in-app, one of these is needed: +1. Backend adds a `productName` field to the `CrossSellV2.OtherCrossSell` type +2. Derive the product type from the `storeUrl` (e.g., parse the URL path) +3. Add a new query field + +Discuss with the backend team which approach they prefer. Until resolved, the in-app flow can be tested via direct navigation (e.g., a debug button or deep link). + +### Key reference files +- Addon purchase flow: `app/feature/feature-addon-purchase/` — simplest existing purchase pattern +- Moving flow: `app/feature/feature-movingflow/` — multi-step form with validation +- Termination flow: `app/feature/feature-terminate-insurance/` — backend-driven navigation +- Login BankID: `app/feature/feature-login/src/main/kotlin/.../BankIdState.kt` — BankID launch +- Racoon GraphQL: `/Users/hugolinder/repos/racoon/apps/store/src/graphql/` — web query patterns diff --git a/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md b/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md new file mode 100644 index 0000000000..bc6a469592 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md @@ -0,0 +1,241 @@ +# In-App Apartment Purchase Flow + +## Overview + +Replace the current link-out to hedvig.com for apartment insurance cross-sells with a fully native Compose purchase flow. Covers SE_APARTMENT_RENT and SE_APARTMENT_BRF products. + +## Scope + +### In scope (v1) +- Manual form entry: street, zipCode, livingSpace, numberCoInsured +- Tier selection: BAS / STANDARD / MAX (racoon-style cards) +- Summary with price confirmation +- BankID signing via existing app launch pattern +- Success / failure screens +- Entry from cross-sell on insurance tab + +### Out of scope (v1) +- Address pre-fill from SSN (SPAR lookup) +- Coverage comparison screen +- Cross-sell / upsell within purchase flow +- Multi-product cart ("add another insurance") +- Payment setup (assumed already done) +- Other product types (car, pet, accident, house) +- Generic/dynamic form engine + +## Module Structure + +New module: `app/feature/feature-purchase-apartment/` + +``` +feature-purchase-apartment/ +├── build.gradle.kts +└── src/main/ + ├── kotlin/com/hedvig/android/feature/purchase/apartment/ + │ ├── navigation/ + │ │ ├── ApartmentPurchaseDestination.kt + │ │ └── ApartmentPurchaseNavGraph.kt + │ ├── data/ + │ │ ├── CreateShopSessionUseCase.kt + │ │ ├── UpdatePriceIntentUseCase.kt + │ │ ├── ConfirmPriceIntentUseCase.kt + │ │ ├── AddToCartAndSignUseCase.kt + │ │ ├── PollSigningStatusUseCase.kt + │ │ └── PurchaseModels.kt + │ ├── ui/ + │ │ ├── form/ + │ │ │ ├── ApartmentFormViewModel.kt + │ │ │ └── ApartmentFormDestination.kt + │ │ ├── offer/ + │ │ │ ├── SelectTierViewModel.kt + │ │ │ └── SelectTierDestination.kt + │ │ ├── summary/ + │ │ │ ├── PurchaseSummaryViewModel.kt + │ │ │ └── PurchaseSummaryDestination.kt + │ │ ├── sign/ + │ │ │ ├── SigningViewModel.kt + │ │ │ └── SigningDestination.kt + │ │ ├── success/ + │ │ │ └── PurchaseSuccessDestination.kt + │ │ └── failure/ + │ │ └── PurchaseFailureDestination.kt + │ └── di/ + │ └── ApartmentPurchaseModule.kt + └── graphql/ + ├── ShopSessionCreateMutation.graphql + ├── PriceIntentCreateMutation.graphql + ├── PriceIntentDataUpdateMutation.graphql + ├── PriceIntentConfirmMutation.graphql + ├── ShopSessionCartEntriesAddMutation.graphql + ├── ShopSessionStartSignMutation.graphql + └── ShopSessionSigningQuery.graphql +``` + +## Architecture + +### Navigation Graph + +``` +CrossSell click (insurance tab) + └─→ ApartmentPurchaseGraphDestination(productType: RENT|BRF) + ├── ApartmentForm (start destination) + ├── SelectTier(offers) + ├── Summary(selectedOffer) + └── Signing(shopSessionId) + +PurchaseSuccess (outside graph, terminal) +PurchaseFailure (inside graph, retryable) +``` + +Following the termination flow pattern: inner `navgraph` for in-progress steps, `PurchaseSuccess` outside so back-press exits the feature. On success, the entire purchase graph is popped. + +### Destinations + +| Destination | Purpose | ViewModel | API calls | +|-------------|---------|-----------|-----------| +| ApartmentForm | Collect address + living details | ApartmentFormViewModel | shopSessionCreate, priceIntentCreate, priceIntentDataUpdate, priceIntentConfirm | +| SelectTier | Show BAS/STANDARD/MAX offers | SelectTierViewModel | None (offers from nav args) | +| Summary | Review selection + confirm | PurchaseSummaryViewModel | shopSessionCartEntriesAdd, shopSessionStartSign | +| Signing | BankID launch + poll | SigningViewModel | shopSessionSigning (poll) | +| PurchaseSuccess | Terminal success | None (stateless) | CrossSellAfterFlowRepository signal | +| PurchaseFailure | Error with retry | None (stateless) | None | + +### State Threading + +Navigation arguments (serializable data classes), not DataStore. The flow is short enough (4 active steps) that nav args suffice. If the app is killed, the user restarts the flow. + +Key serializable types passed through navigation: +- `productType: ApartmentProductType` (RENT or BRF) — passed into the graph +- `shopSessionId: String` — created at form submit, threaded through all subsequent steps +- `offers: List` — generated by priceIntentConfirm, passed to SelectTier +- `selectedOffer: TierOffer` — chosen by user, passed to Summary +- `signingId: String` — returned by shopSessionStartSign, passed to Signing + +### API Flow + +``` +Form submit: + 1. shopSessionCreate(countryCode: SE) → shopSessionId + 2. priceIntentCreate(shopSessionId, productName) → priceIntentId + 3. priceIntentDataUpdate(priceIntentId, {street, zipCode, livingSpace, numberCoInsured}) + 4. priceIntentConfirm(priceIntentId) → ProductOffer[] with tiers + +Tier selection: + (no API call — pure UI) + +Summary confirm: + 5. shopSessionCartEntriesAdd(shopSessionId, [selectedOfferId]) + 6. shopSessionStartSign(shopSessionId) → autoStartToken + signingId + +Signing: + 7. Launch BankID app via autoStartToken URI + 8. Poll shopSessionSigning(signingId) until SIGNED or FAILED +``` + +### Presenter Pattern + +Standard MoleculeViewModel + MoleculePresenter per screen: + +- **ApartmentFormPresenter** — validates inputs using `ValidatedInput` pattern from moving flow. On submit: fires API calls 1-4 sequentially, shows loading state, on success navigates with offers. Validation rules: street (non-blank), zipCode (5 digits), livingSpace (non-null, positive int), numberCoInsured (0+). +- **SelectTierPresenter** — receives offers via nav args. Manages selected tier. Computes price display (net/gross with any bundle discount). Navigation side-effect pattern for proceeding to summary. +- **PurchaseSummaryPresenter** — receives selected offer via nav args. Dual LaunchedEffect pattern (like addon summary): one for loading display data, one for submit. On confirm: adds to cart + starts signing. Navigates to Signing on success. +- **SigningPresenter** — launches BankID via `BankIdState` pattern (autoStartToken → URI → Intent). Polls `shopSessionSigning` with interval. Handles: SIGNED → success, FAILED → failure, timeout → failure. + +### Error Handling + +Three layers, matching existing codebase conventions: +- **Network errors**: `safeExecute()` returns `Either`, mapped to `ErrorMessage` +- **Backend business errors**: `UserError` union member on mutations, surfaced as specific error messages +- **Presenter-level**: Loading/Content/Error sealed states. Submit failures navigate to PurchaseFailure destination. Form validation errors shown inline. + +## UI Design + +### Form Screen +- Standard `HedvigScaffold` with top bar + back navigation +- `HedvigTextField` for street, zipCode, livingSpace +- `HedvigStepper` (or number input) for numberCoInsured +- Submit button at bottom, disabled until all fields valid +- Loading overlay during API calls + +### Tier Selection Screen (racoon-inspired) +- Pillow image with selected tier badge at top +- Price display with bundle discount (strikethrough + discounted price) +- Three tier cards stacked vertically: + - **Selected card (expanded):** dark background, tier name + price badge, description, feature highlights with checkmarks, "Se vad som ingår" button + - **Unselected cards (collapsed):** light background, tier name + price, "Välj {tier}" button +- Tapping an unselected card selects it (expands it, collapses others) +- Continue button at bottom + +### Summary Screen +- Product card: pillow image, product name, address, selected tier +- Price breakdown: monthly cost, any bundle discount +- Confirm/sign button +- Back navigation to change selection + +### Signing Screen +- Loading indicator with "Öppnar BankID..." status text +- BankID app launches automatically +- Poll status with visual feedback +- Timeout handling with retry option + +### Success Screen +- Success illustration/animation +- "Din försäkring är klar" message +- Insurance start date +- Close button (pops entire flow) +- Triggers cross-sell after-flow banner + +## Entry Point Integration + +Modify the cross-sell click handler in the insurance tab: + +1. Add product type info to `CrossSell` data model (derive from backend data or add to GraphQL query) +2. In `InsuranceGraph.kt`, check if product is SE_APARTMENT_RENT or SE_APARTMENT_BRF: + - Yes → navigate to `ApartmentPurchaseGraphDestination(productType)` + - No → continue with `openUrl(storeUrl)` (existing behavior) +3. Register `apartmentPurchaseNavGraph` in the app's main NavHost +4. Add `apartmentPurchaseModule` to the application-level Koin module + +## Dependencies + +### Module dependencies +- `projects.coreCommonPublic` — common utilities +- `projects.navigationCompose` — navigation extensions +- `projects.designSystemHedvig` — UI components +- `projects.apolloOctopusPublic` — GraphQL schema types +- `projects.dataCrossSellAfterFlow` — cross-sell completion signal +- `projects.coreResources` — string resources + +### No dependency on +- Other feature modules (enforced by build plugin) +- Login/auth modules (BankID pattern is replicated, not imported) + +## Build Configuration + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + compose() + apollo("octopus") + serialization() +} + +dependencies { + implementation(projects.coreCommonPublic) + implementation(projects.navigationCompose) + implementation(projects.designSystemHedvig) + implementation(projects.apolloOctopusPublic) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.coreResources) +} +``` + +## Future Extensions + +When adding more products, each would get its own feature module (e.g., `feature-purchase-car/`) with product-specific form fields but sharing the same ShopSession API pattern. Shared components (tier cards, signing screen, success screen) could be extracted to a `ui-purchase-common` module if duplication warrants it. + +A generic form engine (like racoon's PriceTemplate system) could be built later if the number of products grows, replacing product-specific form screens with a dynamic renderer.