diff --git a/.gitignore b/.gitignore index d6d77e4dc1..8cdaf30d30 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ lokalise.properties # Local jks store Jks +.worktrees diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index c754732dc1..a6e227bbf8 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -184,6 +184,8 @@ dependencies { implementation(projects.designSystemInternals) implementation(projects.featureAddonPurchase) implementation(projects.featurePurchaseApartment) + implementation(projects.featurePurchaseCar) + implementation(projects.purchaseCommon) implementation(projects.featureChat) implementation(projects.featureChooseTier) implementation(projects.featureClaimChat) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt index db0104ab6a..4ebc25854f 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt @@ -63,7 +63,6 @@ import com.hedvig.android.datadog.core.di.datadogModule import com.hedvig.android.datadog.demo.tracking.di.datadogDemoTrackingModule import com.hedvig.android.design.system.hedvig.pdfrenderer.PdfDecoder import com.hedvig.android.feature.addon.purchase.di.addonPurchaseModule -import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule import com.hedvig.android.feature.change.tier.di.chooseTierModule import com.hedvig.android.feature.chat.di.chatModule import com.hedvig.android.feature.claim.details.di.claimDetailsModule @@ -80,6 +79,9 @@ import com.hedvig.android.feature.login.di.loginModule import com.hedvig.android.feature.movingflow.di.movingFlowModule import com.hedvig.android.feature.payments.di.paymentsModule import com.hedvig.android.feature.profile.di.profileModule +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule +import com.hedvig.android.feature.purchase.car.di.carPurchaseModule +import com.hedvig.android.feature.purchase.common.di.purchaseCommonModule import com.hedvig.android.feature.terminateinsurance.di.terminateInsuranceModule import com.hedvig.android.feature.travelcertificate.di.travelCertificateModule import com.hedvig.android.featureflags.di.featureManagerModule @@ -292,6 +294,7 @@ val applicationModule = module { addonPurchaseModule, addonRemovalModule, apartmentPurchaseModule, + carPurchaseModule, androidPermissionModule, apolloAuthListenersModule, appModule, @@ -346,6 +349,7 @@ val applicationModule = module { notificationModule, paymentsModule, profileModule, + purchaseCommonModule, settingsDatastoreModule, sharedModule(AndroidBuildConfig()), sharedPreferencesModule, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt index 215bd380ea..3183778f32 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt @@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.lifecycle.compose.dropUnlessResumed import androidx.media3.datasource.cache.SimpleCache import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.NavHost +import androidx.navigation.toRoute import coil3.ImageLoader import com.benasher44.uuid.Uuid import com.hedvig.android.app.ui.HedvigAppState @@ -16,6 +18,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.data.contract.ContractId +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.design.system.hedvig.motion.MotionDefaults import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseGraphDestination @@ -68,6 +71,12 @@ import com.hedvig.android.feature.movingflow.movingFlowGraph import com.hedvig.android.feature.payments.navigation.paymentsGraph import com.hedvig.android.feature.profile.navigation.ProfileDestination import com.hedvig.android.feature.profile.tab.profileGraph +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph +import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseGraphDestination +import com.hedvig.android.feature.purchase.car.navigation.carPurchaseNavGraph +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceGraphDestination import com.hedvig.android.feature.terminateinsurance.navigation.terminateInsuranceGraph import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateGraphDestination @@ -76,17 +85,15 @@ import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.typedPopBackStack import com.hedvig.android.navigation.compose.typedPopUpTo import com.hedvig.android.navigation.core.HedvigDeepLinkContainer -import org.koin.mp.KoinPlatform import com.hedvig.feature.claim.chat.ClaimChatDestination import com.hedvig.feature.claim.chat.claimChatGraph -import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination -import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph import com.hedvig.feature.remove.addons.AddonRemoveGraphDestination import com.hedvig.feature.remove.addons.removeAddonsNavGraph +import org.koin.mp.KoinPlatform @Composable internal fun HedvigNavHost( @@ -327,6 +334,9 @@ internal fun HedvigNavHost( onNavigateToApartmentPurchase = { productName -> navController.navigate(ApartmentPurchaseGraphDestination(productName)) }, + onNavigateToCarPurchase = { productName -> + navController.navigate(CarPurchaseGraphDestination(productName)) + }, ) foreverGraph( hedvigDeepLinkContainer = hedvigDeepLinkContainer, @@ -488,6 +498,21 @@ internal fun HedvigNavHost( finishApp = finishApp, crossSellAfterFlowRepository = crossSellAfterFlowRepository, ) + carPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) + navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.popBackStack()) finishApp() + }, + ) + } removeAddonsNavGraph( navController = hedvigAppState.navController, onNavigateToNewConversation = { diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt index f7902e706a..8b84735e25 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt @@ -42,6 +42,7 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit, navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit, onNavigateToApartmentPurchase: (productName: String) -> Unit, + onNavigateToCarPurchase: (productName: String) -> Unit, ) { navgraph( startDestination = InsurancesDestination.Insurances::class, @@ -62,7 +63,7 @@ fun NavGraphBuilder.insuranceGraph( navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId)) }, onCrossSellClick = dropUnlessResumed { url: String -> - // Hardcoded for testing: route all cross-sells to in-app purchase + // TODO: route based on product type from cross-sell data onNavigateToApartmentPurchase("SE_APARTMENT_RENT") }, navigateToCancelledInsurances = dropUnlessResumed { diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index 58daa4a1f0..06854102a8 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("hedvig.android.library") id("hedvig.gradle.plugin") + alias(libs.plugins.composeScreenshot) } hedvig { @@ -11,6 +12,7 @@ hedvig { android { testOptions.unitTests.isReturnDefaultValues = true + experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { @@ -24,7 +26,6 @@ dependencies { implementation(libs.koin.composeViewModel) implementation(libs.koin.core) implementation(libs.kotlinx.serialization.core) - implementation(libs.zXing) implementation(projects.apolloCore) implementation(projects.apolloOctopusPublic) implementation(projects.composeUi) @@ -33,6 +34,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.dataCrossSellAfterFlow) implementation(projects.designSystemHedvig) + implementation(projects.purchaseCommon) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCommon) @@ -40,6 +42,10 @@ dependencies { implementation(projects.navigationComposeTyped) implementation(projects.navigationCore) + screenshotTestImplementation(libs.androidx.compose.uiTooling) + screenshotTestImplementation(libs.androidx.compose.uiToolingPreview) + screenshotTestImplementation(libs.composeScreenshotValidationApi) + testImplementation(libs.apollo.testingSupport) testImplementation(libs.assertK) testImplementation(libs.coroutines.test) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt index cd67040a71..8866ad42db 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt @@ -23,19 +23,3 @@ internal data class ApartmentTierOffer( val deductibleDisplayName: String?, val hasDiscount: Boolean, ) - -internal data class SigningStart( - val signingId: String, - val autoStartToken: String, -) - -internal data class SigningPollResult( - val status: SigningStatus, - val liveQrCodeData: String?, -) - -internal enum class SigningStatus { - PENDING, - SIGNED, - FAILED, -} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt index 8255554230..06563a35f9 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt @@ -1,25 +1,16 @@ package com.hedvig.android.feature.purchase.apartment.di -import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase -import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCaseImpl import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl -import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase -import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCaseImpl import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel -import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel -import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel -import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val apartmentPurchaseModule = module { single { CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } single { SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) } - single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } - single { PollSigningStatusUseCaseImpl(apolloClient = get()) } viewModel { params -> ApartmentFormViewModel( @@ -28,19 +19,4 @@ val apartmentPurchaseModule = module { submitFormAndGetOffersUseCase = get(), ) } - viewModel { params -> - SelectTierViewModel(params = params.get()) - } - viewModel { params -> - PurchaseSummaryViewModel( - summaryParameters = params.get(), - addToCartAndStartSignUseCase = get(), - ) - } - viewModel { params -> - SigningViewModel( - signingParameters = params.get(), - pollSigningStatusUseCase = get(), - ) - } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt index c03968519d..fb2867cbf2 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt @@ -1,9 +1,6 @@ package com.hedvig.android.feature.purchase.apartment.navigation import com.hedvig.android.navigation.common.Destination -import com.hedvig.android.navigation.common.DestinationNavTypeAware -import kotlin.reflect.KType -import kotlin.reflect.typeOf import kotlinx.serialization.Serializable @Serializable @@ -14,75 +11,4 @@ data class ApartmentPurchaseGraphDestination( internal sealed interface ApartmentPurchaseDestination { @Serializable data object Form : ApartmentPurchaseDestination, Destination - - @Serializable - data class SelectTier( - val params: SelectTierParameters, - ) : ApartmentPurchaseDestination, Destination { - companion object : DestinationNavTypeAware { - override val typeList: List = listOf(typeOf()) - } - } - - @Serializable - data class Summary( - val params: SummaryParameters, - ) : ApartmentPurchaseDestination, Destination { - companion object : DestinationNavTypeAware { - override val typeList: List = listOf(typeOf()) - } - } - - @Serializable - data class Signing( - val params: SigningParameters, - ) : ApartmentPurchaseDestination, Destination { - companion object : DestinationNavTypeAware { - override val typeList: List = listOf(typeOf()) - } - } - - @Serializable - data class Success( - val startDate: String?, - ) : ApartmentPurchaseDestination, Destination - - @Serializable - data object Failure : ApartmentPurchaseDestination, Destination } - -@Serializable -internal data class TierOfferData( - val offerId: String, - val tierDisplayName: String, - val tierDescription: String, - val grossAmount: Double, - val grossCurrencyCode: String, - val netAmount: Double, - val netCurrencyCode: String, - val usps: List, - val exposureDisplayName: String, - val deductibleDisplayName: String?, - val hasDiscount: Boolean, -) - -@Serializable -internal data class SelectTierParameters( - val shopSessionId: String, - val offers: List, - val productDisplayName: String, -) - -@Serializable -internal data class SummaryParameters( - val shopSessionId: String, - val selectedOffer: TierOfferData, - val productDisplayName: String, -) - -@Serializable -internal data class SigningParameters( - val signingId: String, - val autoStartToken: String, - val startDate: String?, -) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt index 11f8293c71..624c61c73c 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt @@ -6,22 +6,25 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.toRoute import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Failure import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Form -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.SelectTier -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Signing -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Success -import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Summary -import com.hedvig.android.feature.purchase.apartment.ui.failure.PurchaseFailureDestination import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel -import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierDestination -import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel -import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningDestination -import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel -import com.hedvig.android.feature.purchase.apartment.ui.success.PurchaseSuccessDestination -import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryDestination -import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.navgraph import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack @@ -97,7 +100,6 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( viewModel = viewModel, navigateUp = dropUnlessResumed { navController.popBackStack() }, navigateToSigning = { params -> navController.navigate(Signing(params)) }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, ) } @@ -116,27 +118,7 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( typedPopUpTo({ inclusive = true }) } }, - navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, ) } - - navdestination { - PurchaseFailureDestination( - onRetry = dropUnlessResumed { navController.popBackStack() }, - close = dropUnlessResumed { - if (!navController.typedPopBackStack(inclusive = true)) finishApp() - }, - ) - } - } - - 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/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index c91931782e..0ebc9aaa22 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ErrorDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress @@ -50,7 +51,6 @@ internal fun ApartmentFormDestination( } HedvigScaffold( navigateUp = navigateUp, - topAppBarText = "Hemförsäkring", ) { when { uiState.isLoadingSession -> { @@ -69,6 +69,13 @@ internal fun ApartmentFormDestination( var livingSpace by remember { mutableStateOf("") } var numberCoInsured by remember { mutableIntStateOf(0) } + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(ApartmentFormEvent.DismissError) }, + ) + } ApartmentFormContent( street = street, zipCode = zipCode, @@ -94,7 +101,6 @@ internal fun ApartmentFormDestination( ), ) }, - onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, ) } } @@ -102,7 +108,7 @@ internal fun ApartmentFormDestination( } @Composable -private fun ApartmentFormContent( +internal fun ApartmentFormContent( street: String, zipCode: String, livingSpace: String, @@ -116,7 +122,6 @@ private fun ApartmentFormContent( onLivingSpaceChanged: (String) -> Unit, onNumberCoInsuredChanged: (Int) -> Unit, onSubmit: () -> Unit, - onRetry: () -> Unit, ) { Column( modifier = Modifier @@ -219,7 +224,6 @@ private fun PreviewApartmentFormEmpty() { onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } @@ -244,7 +248,6 @@ private fun PreviewApartmentFormFilled() { onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } @@ -269,7 +272,6 @@ private fun PreviewApartmentFormWithErrors() { onLivingSpaceChanged = {}, onNumberCoInsuredChanged = {}, onSubmit = {}, - onRetry = {}, ) } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt index 02c5a6e1da..799d2d730a 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt @@ -35,6 +35,8 @@ internal sealed interface ApartmentFormEvent { data object ClearNavigation : ApartmentFormEvent data object Retry : ApartmentFormEvent + + data object DismissError : ApartmentFormEvent } internal data class ApartmentFormState( @@ -99,6 +101,10 @@ private class ApartmentFormPresenter( currentState = currentState.copy(submitError = null) } } + + ApartmentFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt deleted file mode 100644 index 894904d634..0000000000 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ /dev/null @@ -1,290 +0,0 @@ -package com.hedvig.android.feature.purchase.apartment.ui.offer - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.dropUnlessResumed -import com.hedvig.android.design.system.hedvig.HedvigButton -import com.hedvig.android.design.system.hedvig.HedvigCard -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigScaffold -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.RadioGroup -import com.hedvig.android.design.system.hedvig.RadioGroupSize -import com.hedvig.android.design.system.hedvig.RadioOption -import com.hedvig.android.design.system.hedvig.RadioOptionId -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons -import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters -import java.text.NumberFormat -import java.util.Currency -import java.util.Locale - -@Composable -internal fun SelectTierDestination( - viewModel: SelectTierViewModel, - navigateUp: () -> Unit, - onContinueToSummary: (SummaryParameters) -> Unit, -) { - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState.summaryToNavigate != null) { - LaunchedEffect(uiState.summaryToNavigate) { - viewModel.emit(SelectTierEvent.ClearNavigation) - onContinueToSummary(uiState.summaryToNavigate) - } - } - SelectTierContent( - uiState = uiState, - navigateUp = navigateUp, - onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, - onSelectDeductible = { tierName, offerId -> - viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) - }, - onContinue = { viewModel.emit(SelectTierEvent.Continue) }, - ) -} - -@Composable -private fun SelectTierContent( - uiState: SelectTierUiState, - navigateUp: () -> Unit = {}, - onSelectTier: (String) -> Unit = {}, - onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, - onContinue: () -> Unit = {}, -) { - HedvigScaffold( - navigateUp = navigateUp, - ) { - Spacer(Modifier.height(16.dp)) - HedvigText( - text = "Anpassa din f\u00f6rs\u00e4kring", - style = HedvigTheme.typography.headlineMedium, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(Modifier.height(4.dp)) - HedvigText( - text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(Modifier.height(24.dp)) - for ((index, tierGroup) in uiState.tierGroups.withIndex()) { - val isSelected = tierGroup.tierDisplayName == uiState.selectedTierName - val selectedDeductibleId = uiState.selectedDeductibleByTier[tierGroup.tierDisplayName] - val selectedDeductible = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } - ?: tierGroup.deductibleOptions.firstOrNull() - TierGroupCard( - tierGroup = tierGroup, - isSelected = isSelected, - selectedDeductibleId = selectedDeductible?.offerId ?: "", - onSelectTier = { onSelectTier(tierGroup.tierDisplayName) }, - onSelectDeductible = { offerId -> onSelectDeductible(tierGroup.tierDisplayName, offerId) }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - if (index < uiState.tierGroups.lastIndex) { - Spacer(Modifier.height(12.dp)) - } - } - Spacer(Modifier.height(24.dp)) - HedvigButton( - text = "Forts\u00e4tt", - onClick = dropUnlessResumed { onContinue() }, - enabled = uiState.selectedTierName.isNotEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - Spacer(Modifier.height(16.dp)) - } -} - -@Composable -private fun TierGroupCard( - tierGroup: TierGroup, - isSelected: Boolean, - selectedDeductibleId: String, - onSelectTier: () -> Unit, - onSelectDeductible: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } - HedvigCard( - onClick = onSelectTier, - borderColor = if (isSelected) { - HedvigTheme.colorScheme.signalGreenElement - } else { - HedvigTheme.colorScheme.borderSecondary - }, - modifier = modifier, - ) { - Column(Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - HedvigText( - text = tierGroup.tierDisplayName, - style = HedvigTheme.typography.bodyLarge, - ) - if (selectedOption != null) { - HedvigText( - text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), - style = HedvigTheme.typography.bodyLarge, - ) - } - } - AnimatedVisibility( - visible = isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - if (tierGroup.usps.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - for (usp in tierGroup.usps) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp), - ) { - Icon( - HedvigIcons.Checkmark, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = HedvigTheme.colorScheme.signalGreenElement, - ) - Spacer(Modifier.width(8.dp)) - HedvigText( - text = usp, - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, - ) - } - } - } - if (tierGroup.deductibleOptions.size > 1) { - Spacer(Modifier.height(12.dp)) - HedvigText( - text = "Sj\u00e4lvrisk", - style = HedvigTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(4.dp)) - RadioGroup( - options = tierGroup.deductibleOptions.map { option -> - RadioOption( - id = RadioOptionId(option.offerId), - text = option.deductibleDisplayName, - label = formatPrice(option.netAmount, option.netCurrencyCode), - ) - }, - selectedOption = RadioOptionId(selectedDeductibleId), - onRadioOptionSelected = { onSelectDeductible(it.id) }, - size = RadioGroupSize.Small, - ) - } - } - } - AnimatedVisibility( - visible = !isSelected, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - Spacer(Modifier.height(8.dp)) - HedvigText( - text = "V\u00e4lj ${tierGroup.tierDisplayName}", - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, - ) - } - } - } - } -} - -private fun formatPrice(amount: Double, currencyCode: String): String { - @Suppress("DEPRECATION") - val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) - format.currency = Currency.getInstance(currencyCode) - format.maximumFractionDigits = 0 - return "${format.format(amount)}/m\u00e5n" -} - -private val previewTierGroups = listOf( - TierGroup( - tierDisplayName = "Hem Max", - tierDescription = "V\u00e5rt mest omfattande skydd", - usps = listOf( - "F\u00f6rs\u00e4kringsbelopp 1 000 000 kr", - "Drulle upp till 50 000 kr ing\u00e5r", - "ID-skydd och flyttskydd", - ), - deductibleOptions = listOf( - DeductibleOption("1a", "1 500 kr", 189.0, "SEK", 189.0, "SEK", false), - DeductibleOption("1b", "3 000 kr", 169.0, "SEK", 169.0, "SEK", false), - DeductibleOption("1c", "5 000 kr", 149.0, "SEK", 149.0, "SEK", false), - ), - ), - TierGroup( - tierDisplayName = "Hem Standard", - tierDescription = "V\u00e5r mest popul\u00e4ra f\u00f6rs\u00e4kring", - usps = listOf( - "F\u00f6rs\u00e4kringsbelopp 1 000 000 kr", - "Drulle upp till 50 000 kr ing\u00e5r", - ), - deductibleOptions = listOf( - DeductibleOption("2a", "1 500 kr", 139.0, "SEK", 118.0, "SEK", true), - DeductibleOption("2b", "3 000 kr", 119.0, "SEK", 99.0, "SEK", true), - DeductibleOption("2c", "5 000 kr", 99.0, "SEK", 85.0, "SEK", true), - ), - ), - TierGroup( - tierDisplayName = "Hem Bas", - tierDescription = "Inneh\u00e5ller v\u00e5rt grundskydd", - usps = listOf("Grundskydd"), - deductibleOptions = listOf( - DeductibleOption("3a", "1 500 kr", 99.0, "SEK", 99.0, "SEK", false), - DeductibleOption("3b", "3 000 kr", 79.0, "SEK", 79.0, "SEK", false), - DeductibleOption("3c", "5 000 kr", 65.0, "SEK", 65.0, "SEK", false), - ), - ), -) - -@HedvigPreview -@Composable -private fun PreviewSelectTierStandard() { - HedvigTheme { - SelectTierContent( - uiState = SelectTierUiState( - tierGroups = previewTierGroups, - selectedTierName = "Hem Standard", - selectedDeductibleByTier = mapOf( - "Hem Max" to "1c", - "Hem Standard" to "2a", - "Hem Bas" to "3c", - ), - shopSessionId = "session", - productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", - summaryToNavigate = null, - ), - ) - } -} diff --git a/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt b/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt new file mode 100644 index 0000000000..2b9730943e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/screenshotTest/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTest.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface + +@PreviewTest +@Preview(name = "apartment_form_filled", showBackground = true) +@Composable +fun PreviewApartmentFormFilledScreenshot() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + street = "Storgatan 1", + zipCode = "12345", + livingSpace = "65", + numberCoInsured = 1, + streetError = null, + zipCodeError = null, + livingSpaceError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png b/app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png new file mode 100644 index 0000000000..6225f4052a Binary files /dev/null and b/app/feature/feature-purchase-apartment/src/screenshotTestDebug/reference/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormScreenshotTestKt/PreviewApartmentFormFilledScreenshot_apartment_form_filled_d20b0d32_0.png differ diff --git a/app/feature/feature-purchase-car/build.gradle.kts b/app/feature/feature-purchase-car/build.gradle.kts new file mode 100644 index 0000000000..b7d142a990 --- /dev/null +++ b/app/feature/feature-purchase-car/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.purchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql new file mode 100644 index 0000000000..95465ed151 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql @@ -0,0 +1,13 @@ +mutation CarPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...CarProductOfferFragment + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql new file mode 100644 index 0000000000..e7c85131bf --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation CarPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql new file mode 100644 index 0000000000..2cda442a51 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql @@ -0,0 +1,10 @@ +mutation CarPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql new file mode 100644 index 0000000000..5b61c70b67 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarProductOfferFragment.graphql @@ -0,0 +1,50 @@ +fragment CarProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql b/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql new file mode 100644 index 0000000000..1aa036ecda --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation CarShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt new file mode 100644 index 0000000000..6743776e31 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt @@ -0,0 +1,25 @@ +package com.hedvig.android.feature.purchase.car.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class CarOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class CarTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt new file mode 100644 index 0000000000..5ca31dab76 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt @@ -0,0 +1,48 @@ +package com.hedvig.android.feature.purchase.car.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.CarPriceIntentCreateMutation +import octopus.CarShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateCarSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateCarSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateCarSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(CarShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(CarPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt new file mode 100644 index 0000000000..de41566ae1 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt @@ -0,0 +1,105 @@ +package com.hedvig.android.feature.purchase.car.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.CarPriceIntentConfirmMutation +import octopus.CarPriceIntentDataUpdateMutation +import octopus.fragment.CarProductOfferFragment + +internal interface SubmitCarFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either +} + +internal class SubmitCarFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitCarFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("registrationNumber", registrationNumber) + put("mileage", mileage) + put("street", street) + put("zipCode", zipCode) + put("email", email) + } + + val updateResult = apolloClient + .mutation(CarPriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(CarPriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + CarOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +internal fun CarProductOfferFragment.toTierOffer(): CarTierOffer { + return CarTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt new file mode 100644 index 0000000000..a8d681d85b --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt @@ -0,0 +1,22 @@ +package com.hedvig.android.feature.purchase.car.di + +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val carPurchaseModule = module { + single { CreateCarSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitCarFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + CarFormViewModel( + productName = params.get(), + createCarSessionAndPriceIntentUseCase = get(), + submitCarFormAndGetOffersUseCase = get(), + ) + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt new file mode 100644 index 0000000000..8f202e4216 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.feature.purchase.car.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class CarPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface CarPurchaseDestination { + @Serializable + data object Form : CarPurchaseDestination, Destination +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt new file mode 100644 index 0000000000..0b377d9656 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt @@ -0,0 +1,123 @@ +package com.hedvig.android.feature.purchase.car.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseDestination.Form +import com.hedvig.android.feature.purchase.car.ui.form.CarFormDestination +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.carPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination
{ backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: CarFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + CarFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + ) + } + + 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 }) + } + }, + ) + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt new file mode 100644 index 0000000000..7a063599dc --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt @@ -0,0 +1,325 @@ +package com.hedvig.android.feature.purchase.car.ui.form + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownSize +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownStyle +import com.hedvig.android.design.system.hedvig.DropdownItem.SimpleDropdownItem +import com.hedvig.android.design.system.hedvig.DropdownWithDialog +import com.hedvig.android.design.system.hedvig.ErrorDialog +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.car.data.CarOffers + +@Composable +internal fun CarFormDestination( + viewModel: CarFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: CarOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(CarFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + + else -> { + var ssn by remember { mutableStateOf("") } + var registrationNumber by remember { mutableStateOf("") } + var selectedMileage: MileageOption? by remember { mutableStateOf(null) } + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(CarFormEvent.DismissError) }, + ) + } + CarFormContent( + ssn = ssn, + registrationNumber = registrationNumber, + selectedMileage = selectedMileage, + street = street, + zipCode = zipCode, + email = email, + ssnError = uiState.ssnError, + registrationNumberError = uiState.registrationNumberError, + mileageError = uiState.mileageError, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + emailError = uiState.emailError, + isSubmitting = uiState.isSubmitting, + onSsnChanged = { value -> if (value.length <= 12 && value.all { it.isDigit() }) ssn = value }, + onRegistrationNumberChanged = { value -> + val filtered = value.uppercase().filter { it.isLetterOrDigit() || it == ' ' } + if (filtered.length <= 7) registrationNumber = filtered + }, + onMileageSelected = { selectedMileage = it }, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onEmailChanged = { email = it }, + onSubmit = { + viewModel.emit( + CarFormEvent.SubmitForm( + ssn = ssn, + registrationNumber = registrationNumber, + mileage = selectedMileage?.value, + street = street, + zipCode = zipCode, + email = email, + ), + ) + }, + ) + } + } + } +} + +internal enum class MileageOption(val value: Int, val displayName: String) { + MILEAGE_0_1000(1000, "0 - 1 000 mil"), + MILEAGE_1000_1500(1500, "1 000 - 1 500 mil"), + MILEAGE_1500_2000(2000, "1 500 - 2 000 mil"), + MILEAGE_2000_2500(2500, "2 000 - 2 500 mil"), + MILEAGE_2500_PLUS(2501, "2 500+ mil"), +} + +@Composable +private fun CarFormContent( + ssn: String, + registrationNumber: String, + selectedMileage: MileageOption?, + street: String, + zipCode: String, + email: String, + ssnError: String?, + registrationNumberError: String?, + mileageError: String?, + streetError: String?, + zipCodeError: String?, + emailError: String?, + isSubmitting: Boolean, + onSsnChanged: (String) -> Unit, + onRegistrationNumberChanged: (String) -> Unit, + onMileageSelected: (MileageOption) -> Unit, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Fyll i dina uppgifter s\u00e5 ber\u00e4knar vi ditt pris", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HedvigTextField( + text = ssn, + onValueChange = onSsnChanged, + labelText = "Personnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = ssnError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = registrationNumber, + onValueChange = onRegistrationNumberChanged, + labelText = "Registreringsnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = registrationNumberError.toErrorState(), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + DropdownWithDialog( + style = DropdownStyle.Label( + items = MileageOption.entries.map { SimpleDropdownItem(it.displayName) }, + label = "Miltal per \u00e5r", + ), + size = DropdownSize.Medium, + hintText = "V\u00e4lj miltal", + chosenItemIndex = selectedMileage?.let { MileageOption.entries.indexOf(it) }, + onItemChosen = { index -> onMileageSelected(MileageOption.entries[index]) }, + onSelectorClick = {}, + isEnabled = !isSubmitting, + hasError = mileageError != null, + errorText = mileageError, + modifier = Modifier.fillMaxWidth(), + ) + + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = email, + onValueChange = onEmailChanged, + labelText = "E-post", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = emailError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + enabled = !isSubmitting, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Ber\u00e4kna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} + +@HedvigPreview +@Composable +private fun PreviewCarFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + CarFormContent( + ssn = "", + registrationNumber = "", + selectedMileage = null, + street = "", + zipCode = "", + email = "", + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + isSubmitting = false, + onSsnChanged = {}, + onRegistrationNumberChanged = {}, + onMileageSelected = {}, + onStreetChanged = {}, + onZipCodeChanged = {}, + onEmailChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewCarFormFilled() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + CarFormContent( + ssn = "199001011234", + registrationNumber = "ABC 12D", + selectedMileage = MileageOption.MILEAGE_1000_1500, + street = "Storgatan 1", + zipCode = "12345", + email = "test@hedvig.com", + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + isSubmitting = false, + onSsnChanged = {}, + onRegistrationNumberChanged = {}, + onMileageSelected = {}, + onStreetChanged = {}, + onZipCodeChanged = {}, + onEmailChanged = {}, + onSubmit = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt new file mode 100644 index 0000000000..b1adcfa667 --- /dev/null +++ b/app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt @@ -0,0 +1,229 @@ +package com.hedvig.android.feature.purchase.car.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.car.data.CarOffers +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.SessionAndIntent +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class CarFormViewModel( + productName: String, + createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = CarFormState(), + presenter = CarFormPresenter(productName, createCarSessionAndPriceIntentUseCase, submitCarFormAndGetOffersUseCase), + ) + +internal sealed interface CarFormEvent { + data class SubmitForm( + val ssn: String, + val registrationNumber: String, + val mileage: Int?, + val street: String, + val zipCode: String, + val email: String, + ) : CarFormEvent + + data object ClearNavigation : CarFormEvent + + data object Retry : CarFormEvent + + data object DismissError : CarFormEvent +} + +internal data class CarFormState( + val ssnError: String? = null, + val registrationNumberError: String? = null, + val mileageError: String? = null, + val streetError: String? = null, + val zipCodeError: String? = null, + val emailError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: OffersNavigationData? = null, +) + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: CarOffers, +) + +private class CarFormPresenter( + private val productName: String, + private val createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + private val submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: CarFormState): CarFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: CarFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is CarFormEvent.SubmitForm -> { + val errors = validate( + event.ssn, + event.registrationNumber, + event.mileage, + event.street, + event.zipCode, + event.email, + ) + if (errors.hasErrors()) { + currentState = currentState.copy( + ssnError = errors.ssnError, + registrationNumberError = errors.registrationNumberError, + mileageError = errors.mileageError, + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + emailError = errors.emailError, + ) + } else { + currentState = currentState.copy( + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + CarFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + CarFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + + CarFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createCarSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + val mileage = submit.mileage ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitCarFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = submit.ssn, + registrationNumber = submit.registrationNumber, + mileage = mileage, + street = submit.street, + zipCode = submit.zipCode, + email = submit.email, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "Something went wrong", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val ssnError: String?, + val registrationNumberError: String?, + val mileageError: String?, + val streetError: String?, + val zipCodeError: String?, + val emailError: String?, +) { + fun hasErrors(): Boolean = ssnError != null || + registrationNumberError != null || + mileageError != null || + streetError != null || + zipCodeError != null || + emailError != null +} + +private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + +private fun validate( + ssn: String, + registrationNumber: String, + mileage: Int?, + street: String, + zipCode: String, + email: String, +): ValidationErrors { + return ValidationErrors( + ssnError = when { + ssn.length != 12 -> "Ange ett giltigt personnummer (12 siffror)" + !ssn.all { it.isDigit() } -> "Personnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + registrationNumberError = when { + registrationNumber.isBlank() -> "Ange registreringsnummer" + registrationNumber.replace(" ", "").length != 6 -> "Ange ett giltigt registreringsnummer (t.ex. ABC 123)" + else -> null + }, + mileageError = if (mileage == null) "V\u00e4lj miltal" else null, + streetError = if (street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + emailError = when { + email.isBlank() -> "Ange en e-postadress" + !EMAIL_REGEX.matches(email) -> "Ange en giltig e-postadress" + else -> null + }, + ) +} diff --git a/app/purchase-common/build.gradle.kts b/app/purchase-common/build.gradle.kts new file mode 100644 index 0000000000..9dc73af15f --- /dev/null +++ b/app/purchase-common/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.apollo.normalizedCache) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.zXing) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.designSystemHedvig) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql b/app/purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql similarity index 68% rename from app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql rename to app/purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql index 4591859668..fd83e5fe24 100644 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql +++ b/app/purchase-common/src/main/graphql/PurchaseCartEntriesAddMutation.graphql @@ -1,4 +1,4 @@ -mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { +mutation PurchaseCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { shopSession { id diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql b/app/purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql similarity index 81% rename from app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql rename to app/purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql index bc326afc0f..e89bb0ec25 100644 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql +++ b/app/purchase-common/src/main/graphql/PurchaseShopSessionSigningQuery.graphql @@ -1,4 +1,4 @@ -query ApartmentShopSessionSigning($signingId: UUID!) { +query PurchaseShopSessionSigning($signingId: UUID!) { shopSessionSigning(id: $signingId) { id status diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql b/app/purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql similarity index 84% rename from app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql rename to app/purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql index 0067b7708d..e4bf5c63f3 100644 --- a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql +++ b/app/purchase-common/src/main/graphql/PurchaseStartSignMutation.graphql @@ -1,4 +1,4 @@ -mutation ApartmentStartSign($shopSessionId: UUID!) { +mutation PurchaseStartSign($shopSessionId: UUID!) { shopSessionStartSign(shopSessionId: $shopSessionId) { signing { id diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt similarity index 83% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt index ef7705d31e..e4eb051a8c 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.data +package com.hedvig.android.feature.purchase.common.data import arrow.core.Either import arrow.core.raise.either @@ -7,10 +7,10 @@ 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 +import octopus.PurchaseCartEntriesAddMutation +import octopus.PurchaseStartSignMutation -internal interface AddToCartAndStartSignUseCase { +interface AddToCartAndStartSignUseCase { suspend fun invoke(shopSessionId: String, offerId: String): Either } @@ -20,7 +20,7 @@ internal class AddToCartAndStartSignUseCaseImpl( override suspend fun invoke(shopSessionId: String, offerId: String): Either { return either { val cartResult = apolloClient - .mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .mutation(PurchaseCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) .safeExecute() .fold( ifLeft = { @@ -35,7 +35,7 @@ internal class AddToCartAndStartSignUseCaseImpl( } val signResult = apolloClient - .mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId)) + .mutation(PurchaseStartSignMutation(shopSessionId = shopSessionId)) .safeExecute() .fold( ifLeft = { diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt similarity index 88% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt index 0cf4719af1..1d860525df 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.data +package com.hedvig.android.feature.purchase.common.data import arrow.core.Either import arrow.core.raise.either @@ -9,10 +9,10 @@ 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.PurchaseShopSessionSigningQuery import octopus.type.ShopSessionSigningStatus -internal interface PollSigningStatusUseCase { +interface PollSigningStatusUseCase { suspend fun invoke(signingId: String): Either } @@ -22,7 +22,7 @@ internal class PollSigningStatusUseCaseImpl( override suspend fun invoke(signingId: String): Either { return either { apolloClient - .query(ApartmentShopSessionSigningQuery(signingId = signingId)) + .query(PurchaseShopSessionSigningQuery(signingId = signingId)) .fetchPolicy(FetchPolicy.NetworkOnly) .safeExecute() .fold( diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt new file mode 100644 index 0000000000..1283e9372a --- /dev/null +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt @@ -0,0 +1,17 @@ +package com.hedvig.android.feature.purchase.common.data + +data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +data class SigningPollResult( + val status: SigningStatus, + val liveQrCodeData: String?, +) + +enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt new file mode 100644 index 0000000000..a02a597966 --- /dev/null +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.purchase.common.di + +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val purchaseCommonModule = module { + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + SelectTierViewModel(params = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt new file mode 100644 index 0000000000..4fabf7a60e --- /dev/null +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt @@ -0,0 +1,80 @@ +package com.hedvig.android.feature.purchase.common.navigation + +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +sealed interface PurchaseCommonDestination { + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : PurchaseCommonDestination, Destination + + @Serializable + data object Failure : PurchaseCommonDestination, Destination +} + +@Serializable +data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt similarity index 83% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt index a09ae6b6a2..1c90f96b7b 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.ui.failure +package com.hedvig.android.feature.purchase.common.ui.failure import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -9,7 +9,7 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.TopAppBarActionType @Composable -internal fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { +fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { HedvigScaffold( navigateUp = close, topAppBarActionType = TopAppBarActionType.CLOSE, diff --git a/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt new file mode 100644 index 0000000000..2b9e889fbf --- /dev/null +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt @@ -0,0 +1,396 @@ +package com.hedvig.android.feature.purchase.common.ui.offer + +import androidx.compose.foundation.border +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.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.draw.shadow +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownSize.Small +import com.hedvig.android.design.system.hedvig.DropdownDefaults.DropdownStyle.Label +import com.hedvig.android.design.system.hedvig.DropdownItem.SimpleDropdownItem +import com.hedvig.android.design.system.hedvig.DropdownWithDialog +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioGroupStyle +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +@Composable +fun SelectTierDestination( + viewModel: SelectTierViewModel, + navigateUp: () -> Unit, + onContinueToSummary: (SummaryParameters) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + if (uiState.summaryToNavigate != null) { + LaunchedEffect(uiState.summaryToNavigate) { + viewModel.emit(SelectTierEvent.ClearNavigation) + onContinueToSummary(uiState.summaryToNavigate) + } + } + SelectTierContent( + uiState = uiState, + navigateUp = navigateUp, + onSelectTierInDialog = { viewModel.emit(SelectTierEvent.SelectTierInDialog(it)) }, + onConfirmTier = { viewModel.emit(SelectTierEvent.ConfirmTier) }, + onRevertTier = { viewModel.emit(SelectTierEvent.RevertTierToConfirmed) }, + onSelectDeductibleInDialog = { viewModel.emit(SelectTierEvent.SelectDeductibleInDialog(it)) }, + onConfirmDeductible = { viewModel.emit(SelectTierEvent.ConfirmDeductible) }, + onRevertDeductible = { viewModel.emit(SelectTierEvent.RevertDeductibleToConfirmed) }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierContent( + uiState: SelectTierUiState, + navigateUp: () -> Unit = {}, + onSelectTierInDialog: (String) -> Unit = {}, + onConfirmTier: () -> Unit = {}, + onRevertTier: () -> Unit = {}, + onSelectDeductibleInDialog: (String) -> Unit = {}, + onConfirmDeductible: () -> Unit = {}, + onRevertDeductible: () -> 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 skyddsniv\u00e5 och sj\u00e4lvrisk", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + CustomizationCard( + uiState = uiState, + onSelectTierInDialog = onSelectTierInDialog, + onConfirmTier = onConfirmTier, + onRevertTier = onRevertTier, + onSelectDeductibleInDialog = onSelectDeductibleInDialog, + onConfirmDeductible = onConfirmDeductible, + onRevertDeductible = onRevertDeductible, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { onContinue() }, + enabled = uiState.selectedTierName.isNotEmpty() && uiState.selectedDeductible != null, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun CustomizationCard( + uiState: SelectTierUiState, + onSelectTierInDialog: (String) -> Unit, + onConfirmTier: () -> Unit, + onRevertTier: () -> Unit, + onSelectDeductibleInDialog: (String) -> Unit, + onConfirmDeductible: () -> Unit, + onRevertDeductible: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .shadow(elevation = 2.dp, shape = HedvigTheme.shapes.cornerXLarge) + .border( + shape = HedvigTheme.shapes.cornerXLarge, + color = HedvigTheme.colorScheme.borderPrimary, + width = 1.dp, + ), + shape = HedvigTheme.shapes.cornerXLarge, + color = HedvigTheme.colorScheme.backgroundPrimary, + ) { + Column(Modifier.padding(16.dp)) { + DropdownWithDialog( + dialogProperties = DialogProperties(usePlatformDefaultWidth = false), + style = Label( + label = "Skyddsniv\u00e5", + items = uiState.tierGroups.map { SimpleDropdownItem(it.tierDisplayName) }, + ), + size = Small, + hintText = "V\u00e4lj skyddsniv\u00e5", + chosenItemIndex = uiState.selectedTierIndex, + onDoAlongWithDismissRequest = onRevertTier, + containerColor = HedvigTheme.colorScheme.surfacePrimary, + ) { onDismissRequest -> + TierDialogContent( + tierGroups = uiState.tierGroups, + dialogTierName = uiState.dialogTierName, + onSelectTierInDialog = onSelectTierInDialog, + onConfirm = { + onConfirmTier() + onDismissRequest() + }, + onCancel = onDismissRequest, + ) + } + Spacer(Modifier.height(4.dp)) + DropdownWithDialog( + dialogProperties = DialogProperties(usePlatformDefaultWidth = false), + isEnabled = uiState.currentDeductibleOptions.size > 1, + style = Label( + label = "Sj\u00e4lvrisk", + items = uiState.currentDeductibleOptions.map { SimpleDropdownItem(it.deductibleDisplayName) }, + ), + size = Small, + hintText = "V\u00e4lj sj\u00e4lvrisk", + chosenItemIndex = uiState.selectedDeductibleIndex, + onDoAlongWithDismissRequest = onRevertDeductible, + containerColor = HedvigTheme.colorScheme.surfacePrimary, + ) { onDismissRequest -> + DeductibleDialogContent( + deductibleOptions = uiState.currentDeductibleOptions, + dialogDeductibleId = uiState.dialogDeductibleId, + onSelectDeductibleInDialog = onSelectDeductibleInDialog, + onConfirm = { + onConfirmDeductible() + onDismissRequest() + }, + onCancel = onDismissRequest, + ) + } + Spacer(Modifier.height(16.dp)) + val selectedDeductible = uiState.selectedDeductible + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { + HedvigText( + "Totalt", + style = HedvigTheme.typography.bodySmall, + ) + }, + spaceBetween = 8.dp, + endSlot = { + HedvigText( + text = if (selectedDeductible != null) { + formatPrice(selectedDeductible.netAmount, selectedDeductible.netCurrencyCode) + } else { + "-" + }, + textAlign = TextAlign.End, + style = HedvigTheme.typography.bodySmall, + ) + }, + ) + } + } +} + +@Composable +private fun TierDialogContent( + tierGroups: List, + dialogTierName: String?, + onSelectTierInDialog: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + "V\u00e4lj skyddsniv\u00e5", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + HedvigText( + "Skyddsniv\u00e5n avg\u00f6r vad din f\u00f6rs\u00e4kring t\u00e4cker", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + RadioGroup( + options = tierGroups.map { group -> + RadioOption( + id = RadioOptionId(group.tierDisplayName), + text = group.tierDisplayName, + label = group.tierDescription, + ) + }, + selectedOption = dialogTierName?.let { RadioOptionId(it) }, + onRadioOptionSelected = { onSelectTierInDialog(it.id) }, + style = RadioGroupStyle.LeftAligned, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = onConfirm, + enabled = dialogTierName != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = "Avbryt", + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + onClick = onCancel, + ) + } +} + +@Composable +private fun DeductibleDialogContent( + deductibleOptions: List, + dialogDeductibleId: String?, + onSelectDeductibleInDialog: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + "V\u00e4lj sj\u00e4lvrisk", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + HedvigText( + "En h\u00f6gre sj\u00e4lvrisk ger l\u00e4gre m\u00e5nadskostnad", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + RadioGroup( + options = deductibleOptions.map { option -> + RadioOption( + id = RadioOptionId(option.offerId), + text = option.deductibleDisplayName, + label = formatPrice(option.netAmount, option.netCurrencyCode), + ) + }, + selectedOption = dialogDeductibleId?.let { RadioOptionId(it) }, + onRadioOptionSelected = { onSelectDeductibleInDialog(it.id) }, + style = RadioGroupStyle.LeftAligned, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = onConfirm, + enabled = dialogDeductibleId != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = "Avbryt", + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + onClick = onCancel, + ) + } +} + +private fun formatPrice(amount: Double, currencyCode: String): String { + @Suppress("DEPRECATION") + val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) + 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", + ), + dialogTierName = null, + dialogDeductibleId = null, + 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/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt similarity index 56% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt index bebd2f7edf..dc63a37a25 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt @@ -1,18 +1,18 @@ -package com.hedvig.android.feature.purchase.apartment.ui.offer +package com.hedvig.android.feature.purchase.common.ui.offer import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.hedvig.android.feature.purchase.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.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel -internal class SelectTierViewModel( +class SelectTierViewModel( params: SelectTierParameters, ) : MoleculeViewModel( buildInitialState(params), @@ -31,6 +31,8 @@ private fun buildInitialState(params: SelectTierParameters): SelectTierUiState { tierGroups = tierGroups, selectedTierName = defaultTierName, selectedDeductibleByTier = defaultDeductibleByTier, + dialogTierName = null, + dialogDeductibleId = null, shopSessionId = params.shopSessionId, productDisplayName = params.productDisplayName, summaryToNavigate = null, @@ -59,23 +61,52 @@ private fun groupOffersByTier(offers: List): List { } } -internal class SelectTierPresenter( +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 dialogTierName: String? by remember { mutableStateOf(lastState.dialogTierName) } + var dialogDeductibleId: String? by remember { mutableStateOf(lastState.dialogDeductibleId) } var summaryToNavigate: SummaryParameters? by remember { mutableStateOf(lastState.summaryToNavigate) } CollectEvents { event -> when (event) { - is SelectTierEvent.SelectTier -> { - selectedTierName = event.tierName + is SelectTierEvent.SelectTierInDialog -> { + dialogTierName = event.tierName } - is SelectTierEvent.SelectDeductible -> { - selectedDeductibleByTier = selectedDeductibleByTier + (event.tierName to event.offerId) + is SelectTierEvent.SelectDeductibleInDialog -> { + dialogDeductibleId = event.offerId + } + + SelectTierEvent.ConfirmTier -> { + val newTierName = dialogTierName ?: return@CollectEvents + selectedTierName = newTierName + val group = lastState.tierGroups.firstOrNull { it.tierDisplayName == newTierName } + if (group != null) { + val cheapest = group.deductibleOptions.minByOrNull { it.netAmount } + if (cheapest != null) { + selectedDeductibleByTier = selectedDeductibleByTier + (newTierName to cheapest.offerId) + } + } + dialogTierName = null + } + + SelectTierEvent.ConfirmDeductible -> { + val newDeductibleId = dialogDeductibleId ?: return@CollectEvents + selectedDeductibleByTier = selectedDeductibleByTier + (selectedTierName to newDeductibleId) + dialogDeductibleId = null + } + + SelectTierEvent.RevertTierToConfirmed -> { + dialogTierName = null + } + + SelectTierEvent.RevertDeductibleToConfirmed -> { + dialogDeductibleId = null } SelectTierEvent.Continue -> { @@ -98,6 +129,8 @@ internal class SelectTierPresenter( tierGroups = lastState.tierGroups, selectedTierName = selectedTierName, selectedDeductibleByTier = selectedDeductibleByTier, + dialogTierName = dialogTierName, + dialogDeductibleId = dialogDeductibleId, shopSessionId = params.shopSessionId, productDisplayName = params.productDisplayName, summaryToNavigate = summaryToNavigate, @@ -105,14 +138,14 @@ internal class SelectTierPresenter( } } -internal data class TierGroup( +data class TierGroup( val tierDisplayName: String, val tierDescription: String, val usps: List, val deductibleOptions: List, ) -internal data class DeductibleOption( +data class DeductibleOption( val offerId: String, val deductibleDisplayName: String, val netAmount: Double, @@ -122,19 +155,48 @@ internal data class DeductibleOption( val hasDiscount: Boolean, ) -internal data class SelectTierUiState( +data class SelectTierUiState( val tierGroups: List, val selectedTierName: String, val selectedDeductibleByTier: Map, + val dialogTierName: String?, + val dialogDeductibleId: String?, val shopSessionId: String, val productDisplayName: String, val summaryToNavigate: SummaryParameters?, -) +) { + val selectedTierIndex: Int? + get() = tierGroups.indexOfFirst { it.tierDisplayName == selectedTierName }.takeIf { it >= 0 } + + val selectedDeductibleIndex: Int? + get() { + val group = tierGroups.firstOrNull { it.tierDisplayName == selectedTierName } ?: return null + val deductibleId = selectedDeductibleByTier[selectedTierName] ?: return null + return group.deductibleOptions.indexOfFirst { it.offerId == deductibleId }.takeIf { it >= 0 } + } + + val currentDeductibleOptions: List + get() = tierGroups.firstOrNull { it.tierDisplayName == selectedTierName }?.deductibleOptions ?: emptyList() + + val selectedDeductible: DeductibleOption? + get() { + val deductibleId = selectedDeductibleByTier[selectedTierName] ?: return null + return currentDeductibleOptions.firstOrNull { it.offerId == deductibleId } + } +} + +sealed interface SelectTierEvent { + data class SelectTierInDialog(val tierName: String) : SelectTierEvent + + data class SelectDeductibleInDialog(val offerId: String) : SelectTierEvent + + data object ConfirmTier : SelectTierEvent + + data object ConfirmDeductible : SelectTierEvent -internal sealed interface SelectTierEvent { - data class SelectTier(val tierName: String) : SelectTierEvent + data object RevertTierToConfirmed : SelectTierEvent - data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent + data object RevertDeductibleToConfirmed : SelectTierEvent data object Continue : SelectTierEvent diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt similarity index 67% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt index 490eff39a7..131b3cec3f 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.ui.sign +package com.hedvig.android.feature.purchase.common.ui.sign import android.annotation.SuppressLint import android.content.Context @@ -21,7 +21,6 @@ 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 @@ -31,14 +30,17 @@ import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.zxing.BarcodeFormat import com.google.zxing.common.BitMatrix import com.google.zxing.qrcode.QRCodeWriter +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress -import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.logger.LogPriority @@ -47,11 +49,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @Composable -internal fun SigningDestination( - viewModel: SigningViewModel, - navigateToSuccess: (startDate: String?) -> Unit, - navigateToFailure: () -> Unit, -) { +fun SigningDestination(viewModel: SigningViewModel, navigateToSuccess: (startDate: String?) -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val canOpenBankId = remember { canBankIdAppHandleUri(context) } @@ -64,11 +62,8 @@ internal fun SigningDestination( hasNavigated = true navigateToSuccess(state.startDate) } - is SigningUiState.Failed -> { - hasNavigated = true - navigateToFailure() - } - is SigningUiState.Polling -> {} + + else -> {} } } @@ -80,65 +75,63 @@ internal fun SigningDestination( context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) viewModel.emit(SigningEvent.BankIdOpened) } - HedvigFullScreenCenterAlignedProgress() - } else if (!canOpenBankId) { - QrCodeSigningScreen( - liveQrCodeData = state.liveQrCodeData, - onOpenBankId = { - val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") - context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) - viewModel.emit(SigningEvent.BankIdOpened) - }, + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.weight(1f)) + if (state.liveQrCodeData != null) { + QRCode( + data = state.liveQrCodeData, + modifier = Modifier.size(180.dp), + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } + Spacer(Modifier.height(32.dp)) + HedvigText( + text = "Signera med BankID", + style = HedvigTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + HedvigText( + text = "Skanna QR-koden med BankID-appen", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, ) - } else { - HedvigFullScreenCenterAlignedProgress() + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) + if (canOpenBankId) { + HedvigButton( + text = "\u00d6ppna BankID", + onClick = { + val bankIdUri = + Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + }, + enabled = true, + buttonSize = Large, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } } } - is SigningUiState.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(), + is SigningUiState.Failed -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(SigningEvent.Retry) }, ) } - Spacer(Modifier.weight(1f)) + + is SigningUiState.Success -> { + HedvigFullScreenCenterAlignedProgress() + } } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt similarity index 71% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt index 49e98d1094..5d15a331b8 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt @@ -1,20 +1,21 @@ -package com.hedvig.android.feature.purchase.apartment.ui.sign +package com.hedvig.android.feature.purchase.common.ui.sign import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -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.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.SigningStatus +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import kotlinx.coroutines.delay -internal class SigningViewModel( +class SigningViewModel( signingParameters: SigningParameters, pollSigningStatusUseCase: PollSigningStatusUseCase, ) : MoleculeViewModel( @@ -27,7 +28,7 @@ internal class SigningViewModel( presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), ) -internal class SigningPresenter( +class SigningPresenter( private val signingParameters: SigningParameters, private val pollSigningStatusUseCase: PollSigningStatusUseCase, ) : MoleculePresenter { @@ -36,21 +37,34 @@ internal class SigningPresenter( var bankIdOpened by remember { mutableStateOf((lastState as? SigningUiState.Polling)?.bankIdOpened ?: false) } var currentState by remember { mutableStateOf(lastState) } + var pollIteration by remember { mutableIntStateOf(0) } + CollectEvents { event -> when (event) { SigningEvent.BankIdOpened -> { bankIdOpened = true } + SigningEvent.Retry -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = null, + bankIdOpened = bankIdOpened, + ) + pollIteration++ + } + SigningEvent.ClearNavigation -> {} } } - LaunchedEffect(Unit) { + LaunchedEffect(pollIteration) { + if (currentState is SigningUiState.Failed) return@LaunchedEffect while (true) { pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( - ifLeft = { - currentState = SigningUiState.Failed + ifLeft = { error -> + currentState = SigningUiState.Failed(error.message) return@LaunchedEffect }, ifRight = { pollResult -> @@ -61,7 +75,7 @@ internal class SigningPresenter( } SigningStatus.FAILED -> { - currentState = SigningUiState.Failed + currentState = SigningUiState.Failed("Signeringen misslyckades") return@LaunchedEffect } @@ -87,7 +101,7 @@ internal class SigningPresenter( } } -internal sealed interface SigningUiState { +sealed interface SigningUiState { data class Polling( val autoStartToken: String, val startDate: String?, @@ -97,11 +111,13 @@ internal sealed interface SigningUiState { data class Success(val startDate: String?) : SigningUiState - data object Failed : SigningUiState + data class Failed(val errorMessage: String?) : SigningUiState } -internal sealed interface SigningEvent { +sealed interface SigningEvent { data object BankIdOpened : SigningEvent + data object Retry : 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/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt similarity index 90% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt index 8edeb135ab..6416997d25 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.ui.success +package com.hedvig.android.feature.purchase.common.ui.success import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -18,7 +18,7 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.TopAppBarActionType @Composable -internal fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { +fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { HedvigScaffold( navigateUp = close, topAppBarActionType = TopAppBarActionType.CLOSE, @@ -26,7 +26,7 @@ internal fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { ) { Spacer(Modifier.weight(1f)) HedvigText( - text = "Din försäkring är klar!", + text = "Din f\u00f6rs\u00e4kring \u00e4r klar!", style = HedvigTheme.typography.headlineMedium, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -41,7 +41,7 @@ internal fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { } Spacer(Modifier.weight(1f)) HedvigButton( - text = "Stäng", + text = "St\u00e4ng", modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), buttonStyle = Primary, buttonSize = Large, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt similarity index 88% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt index 8cc0899aac..4235029cfb 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.ui.summary +package com.hedvig.android.feature.purchase.common.ui.summary import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.ErrorDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigCard import com.hedvig.android.design.system.hedvig.HedvigPreview @@ -24,16 +25,15 @@ 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 +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData @Composable -internal fun PurchaseSummaryDestination( +fun PurchaseSummaryDestination( viewModel: PurchaseSummaryViewModel, navigateUp: () -> Unit, navigateToSigning: (SigningParameters) -> Unit, - navigateToFailure: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -43,10 +43,12 @@ internal fun PurchaseSummaryDestination( navigateToSigning(signing) } - LaunchedEffect(uiState.navigateToFailure) { - if (!uiState.navigateToFailure) return@LaunchedEffect - viewModel.emit(PurchaseSummaryEvent.ClearNavigation) - navigateToFailure() + if (uiState.submitError != null) { + ErrorDialog( + title = "N\u00e5got gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(PurchaseSummaryEvent.DismissError) }, + ) } PurchaseSummaryScreen( @@ -163,7 +165,7 @@ private fun PreviewPurchaseSummary() { selectedOffer = TierOfferData( offerId = "1", tierDisplayName = "Hem Standard", - tierDescription = "Vår mest populära försäkring", + tierDescription = "V\u00e5r mest popul\u00e4ra f\u00f6rs\u00e4kring", grossAmount = 139.0, grossCurrencyCode = "SEK", netAmount = 118.0, @@ -173,7 +175,7 @@ private fun PreviewPurchaseSummary() { deductibleDisplayName = "1 500 kr", hasDiscount = true, ), - productDisplayName = "Hemförsäkring Hyresrätt", + productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", ), isSubmitting = false, navigateUp = {}, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt similarity index 75% rename from app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt rename to app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt index 02348fb2af..1f1f67c5fd 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt +++ b/app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.purchase.apartment.ui.summary +package com.hedvig.android.feature.purchase.common.ui.summary import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -7,14 +7,14 @@ 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.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel -internal class PurchaseSummaryViewModel( +class PurchaseSummaryViewModel( summaryParameters: SummaryParameters, addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, ) : MoleculeViewModel( @@ -22,7 +22,7 @@ internal class PurchaseSummaryViewModel( params = summaryParameters, isSubmitting = false, signingToNavigate = null, - navigateToFailure = false, + submitError = null, ), presenter = PurchaseSummaryPresenter( summaryParameters, @@ -30,7 +30,7 @@ internal class PurchaseSummaryViewModel( ), ) -internal class PurchaseSummaryPresenter( +class PurchaseSummaryPresenter( private val summaryParameters: SummaryParameters, private val addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, ) : MoleculePresenter { @@ -41,7 +41,7 @@ internal class PurchaseSummaryPresenter( var confirmIteration by remember { mutableIntStateOf(0) } var isSubmitting by remember { mutableStateOf(lastState.isSubmitting) } var signingToNavigate by remember { mutableStateOf(lastState.signingToNavigate) } - var navigateToFailure by remember { mutableStateOf(lastState.navigateToFailure) } + var submitError by remember { mutableStateOf(lastState.submitError) } CollectEvents { event -> when (event) { @@ -51,7 +51,11 @@ internal class PurchaseSummaryPresenter( PurchaseSummaryEvent.ClearNavigation -> { signingToNavigate = null - navigateToFailure = false + submitError = null + } + + PurchaseSummaryEvent.DismissError -> { + submitError = null } } } @@ -63,9 +67,9 @@ internal class PurchaseSummaryPresenter( summaryParameters.shopSessionId, summaryParameters.selectedOffer.offerId, ).fold( - ifLeft = { + ifLeft = { error -> isSubmitting = false - navigateToFailure = true + submitError = error.message ?: "N\u00e5got gick fel" }, ifRight = { signingStart -> isSubmitting = false @@ -83,20 +87,22 @@ internal class PurchaseSummaryPresenter( params = summaryParameters, isSubmitting = isSubmitting, signingToNavigate = signingToNavigate, - navigateToFailure = navigateToFailure, + submitError = submitError, ) } } -internal data class PurchaseSummaryUiState( +data class PurchaseSummaryUiState( val params: SummaryParameters, val isSubmitting: Boolean, val signingToNavigate: SigningParameters?, - val navigateToFailure: Boolean, + val submitError: String?, ) -internal sealed interface PurchaseSummaryEvent { +sealed interface PurchaseSummaryEvent { data object Confirm : PurchaseSummaryEvent data object ClearNavigation : PurchaseSummaryEvent + + data object DismissError : PurchaseSummaryEvent } diff --git a/build.gradle.kts b/build.gradle.kts index 2b72ffd0cd..2553273ff0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.cacheFix) apply false alias(libs.plugins.composeJetbrainsCompilerGradlePlugin) apply false alias(libs.plugins.composeKotlinCompilerGradlePlugin) apply false + alias(libs.plugins.composeScreenshot) apply false alias(libs.plugins.crashlytics) apply false alias(libs.plugins.datadog) apply false alias(libs.plugins.dependencyAnalysis) diff --git a/docs/superpowers/plans/2026-03-31-car-purchase-flow.md b/docs/superpowers/plans/2026-03-31-car-purchase-flow.md new file mode 100644 index 0000000000..b209fd813e --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-car-purchase-flow.md @@ -0,0 +1,2967 @@ +# Car Purchase Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract shared purchase screens into `feature-purchase-common`, create `feature-purchase-car` with car-specific form, and wire both into the app navigation. + +**Architecture:** Three-module approach — shared screens/use-cases/models in `feature-purchase-common`, product-specific forms and GraphQL in `feature-purchase-apartment` and `feature-purchase-car`. Both product modules depend on common but not each other. + +**Tech Stack:** Kotlin, Jetpack Compose, Apollo GraphQL, Molecule (MVI), Koin DI, Arrow (Either), kotlinx.serialization, ZXing (QR codes) + +--- + +### Task 1: Create `feature-purchase-common` module with shared models and GraphQL + +**Files:** +- Create: `app/feature/feature-purchase-common/build.gradle.kts` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionStartSignMutation.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionSigningQuery.graphql` +- Create: `app/feature/feature-purchase-common/src/main/graphql/ProductOfferFragment.graphql` + +- [ ] **Step 1: Create `build.gradle.kts`** + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.apollo.normalizedCache) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.zXing) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.designSystemHedvig) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} +``` + +- [ ] **Step 2: Create shared models** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PurchaseCommonModels.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.data + +data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +data class SigningPollResult( + val status: SigningStatus, + val liveQrCodeData: String?, +) + +enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} +``` + +- [ ] **Step 3: Create shared navigation models** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/navigation/PurchaseCommonDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.navigation + +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) + +sealed interface PurchaseCommonDestination { + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : PurchaseCommonDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : PurchaseCommonDestination, Destination + + @Serializable + data object Failure : PurchaseCommonDestination, Destination +} +``` + +- [ ] **Step 4: Create GraphQL operations** + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +```graphql +mutation PurchaseCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionStartSignMutation.graphql` +```graphql +mutation PurchaseStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ShopSessionSigningQuery.graphql` +```graphql +query PurchaseShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-common/src/main/graphql/ProductOfferFragment.graphql` +```graphql +fragment PurchaseProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/feature/feature-purchase-common/ +git commit -m "feat: scaffold feature-purchase-common module with shared models and GraphQL" +``` + +--- + +### Task 2: Move shared use cases to `feature-purchase-common` + +**Files:** +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt` + +- [ ] **Step 1: Create `AddToCartAndStartSignUseCase`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/AddToCartAndStartSignUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.PurchaseCartEntriesAddMutation +import octopus.PurchaseStartSignMutation + +interface AddToCartAndStartSignUseCase { + suspend fun invoke(shopSessionId: String, offerId: String): Either +} + +internal class AddToCartAndStartSignUseCaseImpl( + private val apolloClient: ApolloClient, +) : AddToCartAndStartSignUseCase { + override suspend fun invoke(shopSessionId: String, offerId: String): Either { + return either { + val cartResult = apolloClient + .mutation(PurchaseCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to add to cart: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCartEntriesAdd }, + ) + + if (cartResult.userError != null) { + raise(ErrorMessage(cartResult.userError?.message)) + } + + val signResult = apolloClient + .mutation(PurchaseStartSignMutation(shopSessionId = shopSessionId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to start signing: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionStartSign }, + ) + + if (signResult.userError != null) { + raise(ErrorMessage(signResult.userError?.message)) + } + + val signing = signResult.signing ?: run { + logcat(LogPriority.ERROR) { "No signing session returned" } + raise(ErrorMessage()) + } + + val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run { + logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" } + raise(ErrorMessage()) + } + + SigningStart( + signingId = signing.id, + autoStartToken = autoStartToken, + ) + } + } +} +``` + +- [ ] **Step 2: Create `PollSigningStatusUseCase`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/PollSigningStatusUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.PurchaseShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +interface PollSigningStatusUseCase { + suspend fun invoke(signingId: String): Either +} + +internal class PollSigningStatusUseCaseImpl( + private val apolloClient: ApolloClient, +) : PollSigningStatusUseCase { + override suspend fun invoke(signingId: String): Either { + return either { + apolloClient + .query(PurchaseShopSessionSigningQuery(signingId = signingId)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + val signing = result.shopSessionSigning + val status = when (signing.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__, + -> SigningStatus.PENDING + } + SigningPollResult( + status = status, + liveQrCodeData = signing.seBankidProperties?.liveQrCodeData, + ) + }, + ) + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/data/ +git commit -m "feat: add shared AddToCartAndStartSign and PollSigningStatus use cases" +``` + +--- + +### Task 3: Move shared screens to `feature-purchase-common` + +**Files:** +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt` +- Create: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt` + +- [ ] **Step 1: Move `SelectTierViewModel.kt`** + +Copy from `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt` — update package to `com.hedvig.android.feature.purchase.common.ui.offer` and update imports from `com.hedvig.android.feature.purchase.apartment.navigation.*` to `com.hedvig.android.feature.purchase.common.navigation.*`. Remove `internal` visibility from `SelectTierViewModel`, `SelectTierPresenter`, `TierGroup`, `DeductibleOption`, `SelectTierUiState`, and `SelectTierEvent` since they'll be consumed cross-module. + +Full file: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.offer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class SelectTierViewModel( + params: SelectTierParameters, +) : MoleculeViewModel( + buildInitialState(params), + SelectTierPresenter(params), +) + +private fun buildInitialState(params: SelectTierParameters): SelectTierUiState { + val tierGroups = groupOffersByTier(params.offers) + val defaultTierName = tierGroups.firstOrNull { "Standard" in it.tierDisplayName }?.tierDisplayName + ?: tierGroups.firstOrNull()?.tierDisplayName + ?: "" + val defaultDeductibleByTier = tierGroups.associate { group -> + group.tierDisplayName to (group.deductibleOptions.minByOrNull { it.netAmount }?.offerId ?: "") + } + return SelectTierUiState( + tierGroups = tierGroups, + selectedTierName = defaultTierName, + selectedDeductibleByTier = defaultDeductibleByTier, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = null, + ) +} + +private fun groupOffersByTier(offers: List): List { + return offers.groupBy { it.tierDisplayName }.map { (tierName, tierOffers) -> + val first = tierOffers.first() + TierGroup( + tierDisplayName = tierName, + tierDescription = first.tierDescription, + usps = first.usps, + deductibleOptions = tierOffers.map { offer -> + DeductibleOption( + offerId = offer.offerId, + deductibleDisplayName = offer.deductibleDisplayName ?: "", + netAmount = offer.netAmount, + netCurrencyCode = offer.netCurrencyCode, + grossAmount = offer.grossAmount, + grossCurrencyCode = offer.grossCurrencyCode, + hasDiscount = offer.hasDiscount, + ) + }.sortedBy { it.netAmount }, + ) + } +} + +class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { + var selectedTierName by remember { mutableStateOf(lastState.selectedTierName) } + var selectedDeductibleByTier by remember { mutableStateOf(lastState.selectedDeductibleByTier) } + var summaryToNavigate: SummaryParameters? by remember { mutableStateOf(lastState.summaryToNavigate) } + + CollectEvents { event -> + when (event) { + is SelectTierEvent.SelectTier -> { + selectedTierName = event.tierName + } + + is SelectTierEvent.SelectDeductible -> { + selectedDeductibleByTier = selectedDeductibleByTier + (event.tierName to event.offerId) + } + + SelectTierEvent.Continue -> { + val selectedOfferId = selectedDeductibleByTier[selectedTierName] ?: return@CollectEvents + val selectedOffer = params.offers.first { it.offerId == selectedOfferId } + summaryToNavigate = SummaryParameters( + shopSessionId = params.shopSessionId, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } + } + } + + return SelectTierUiState( + tierGroups = lastState.tierGroups, + selectedTierName = selectedTierName, + selectedDeductibleByTier = selectedDeductibleByTier, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +data class TierGroup( + val tierDisplayName: String, + val tierDescription: String, + val usps: List, + val deductibleOptions: List, +) + +data class DeductibleOption( + val offerId: String, + val deductibleDisplayName: String, + val netAmount: Double, + val netCurrencyCode: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val hasDiscount: Boolean, +) + +data class SelectTierUiState( + val tierGroups: List, + val selectedTierName: String, + val selectedDeductibleByTier: Map, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters?, +) + +sealed interface SelectTierEvent { + data class SelectTier(val tierName: String) : SelectTierEvent + data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent +} +``` + +- [ ] **Step 2: Move `SelectTierDestination.kt`** + +Copy from apartment module, update package to `com.hedvig.android.feature.purchase.common.ui.offer`, update imports to use `com.hedvig.android.feature.purchase.common.navigation.SummaryParameters`. Remove `internal` visibility. + +Full file: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.offer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioGroupSize +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +@Composable +fun SelectTierDestination( + viewModel: SelectTierViewModel, + navigateUp: () -> Unit, + onContinueToSummary: (SummaryParameters) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + if (uiState.summaryToNavigate != null) { + LaunchedEffect(uiState.summaryToNavigate) { + viewModel.emit(SelectTierEvent.ClearNavigation) + onContinueToSummary(uiState.summaryToNavigate) + } + } + SelectTierContent( + uiState = uiState, + navigateUp = navigateUp, + onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, + onSelectDeductible = { tierName, offerId -> + viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) + }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierContent( + uiState: SelectTierUiState, + navigateUp: () -> Unit = {}, + onSelectTier: (String) -> Unit = {}, + onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, + onContinue: () -> Unit = {}, +) { + HedvigScaffold( + navigateUp = navigateUp, + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Anpassa din f\u00f6rs\u00e4kring", + style = HedvigTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(24.dp)) + for ((index, tierGroup) in uiState.tierGroups.withIndex()) { + val isSelected = tierGroup.tierDisplayName == uiState.selectedTierName + val selectedDeductibleId = uiState.selectedDeductibleByTier[tierGroup.tierDisplayName] + val selectedDeductible = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } + ?: tierGroup.deductibleOptions.firstOrNull() + TierGroupCard( + tierGroup = tierGroup, + isSelected = isSelected, + selectedDeductibleId = selectedDeductible?.offerId ?: "", + onSelectTier = { onSelectTier(tierGroup.tierDisplayName) }, + onSelectDeductible = { offerId -> onSelectDeductible(tierGroup.tierDisplayName, offerId) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + if (index < uiState.tierGroups.lastIndex) { + Spacer(Modifier.height(12.dp)) + } + } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { onContinue() }, + enabled = uiState.selectedTierName.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun TierGroupCard( + tierGroup: TierGroup, + isSelected: Boolean, + selectedDeductibleId: String, + onSelectTier: () -> Unit, + onSelectDeductible: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } + HedvigCard( + onClick = onSelectTier, + borderColor = if (isSelected) { + HedvigTheme.colorScheme.signalGreenElement + } else { + HedvigTheme.colorScheme.borderSecondary + }, + modifier = modifier, + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = tierGroup.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + if (selectedOption != null) { + HedvigText( + text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), + style = HedvigTheme.typography.bodyLarge, + ) + } + } + AnimatedVisibility( + visible = isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + if (tierGroup.usps.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + for (usp in tierGroup.usps) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + HedvigIcons.Checkmark, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = HedvigTheme.colorScheme.signalGreenElement, + ) + Spacer(Modifier.width(8.dp)) + HedvigText( + text = usp, + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + if (tierGroup.deductibleOptions.size > 1) { + Spacer(Modifier.height(12.dp)) + HedvigText( + text = "Sj\u00e4lvrisk", + style = HedvigTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(4.dp)) + RadioGroup( + options = tierGroup.deductibleOptions.map { option -> + RadioOption( + id = RadioOptionId(option.offerId), + text = option.deductibleDisplayName, + label = formatPrice(option.netAmount, option.netCurrencyCode), + ) + }, + selectedOption = RadioOptionId(selectedDeductibleId), + onRadioOptionSelected = { onSelectDeductible(it.id) }, + size = RadioGroupSize.Small, + ) + } + } + } + AnimatedVisibility( + visible = !isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "V\u00e4lj ${tierGroup.tierDisplayName}", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + } +} + +private fun formatPrice(amount: Double, currencyCode: String): String { + @Suppress("DEPRECATION") + val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) + format.currency = Currency.getInstance(currencyCode) + format.maximumFractionDigits = 0 + return "${format.format(amount)}/m\u00e5n" +} +``` + +- [ ] **Step 3: Move `PurchaseSummaryViewModel.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.summary + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = false, + signingToNavigate = null, + navigateToFailure = false, + ), + presenter = PurchaseSummaryPresenter( + summaryParameters, + addToCartAndStartSignUseCase, + ), +) + +class PurchaseSummaryPresenter( + private val summaryParameters: SummaryParameters, + private val addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PurchaseSummaryUiState, + ): PurchaseSummaryUiState { + var confirmIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(lastState.isSubmitting) } + var signingToNavigate by remember { mutableStateOf(lastState.signingToNavigate) } + var navigateToFailure by remember { mutableStateOf(lastState.navigateToFailure) } + + CollectEvents { event -> + when (event) { + PurchaseSummaryEvent.Confirm -> { + confirmIteration++ + } + + PurchaseSummaryEvent.ClearNavigation -> { + signingToNavigate = null + navigateToFailure = false + } + } + } + + LaunchedEffect(confirmIteration) { + if (confirmIteration > 0) { + isSubmitting = true + addToCartAndStartSignUseCase.invoke( + summaryParameters.shopSessionId, + summaryParameters.selectedOffer.offerId, + ).fold( + ifLeft = { + isSubmitting = false + navigateToFailure = true + }, + ifRight = { signingStart -> + isSubmitting = false + signingToNavigate = SigningParameters( + signingId = signingStart.signingId, + autoStartToken = signingStart.autoStartToken, + startDate = null, + ) + }, + ) + } + } + + return PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = isSubmitting, + signingToNavigate = signingToNavigate, + navigateToFailure = navigateToFailure, + ) + } +} + +data class PurchaseSummaryUiState( + val params: SummaryParameters, + val isSubmitting: Boolean, + val signingToNavigate: SigningParameters?, + val navigateToFailure: Boolean, +) + +sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent +} +``` + +- [ ] **Step 4: Move `PurchaseSummaryDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/summary/PurchaseSummaryDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.summary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData + +@Composable +fun PurchaseSummaryDestination( + viewModel: PurchaseSummaryViewModel, + navigateUp: () -> Unit, + navigateToSigning: (SigningParameters) -> Unit, + navigateToFailure: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.signingToNavigate) { + val signing = uiState.signingToNavigate ?: return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + navigateToSigning(signing) + } + + LaunchedEffect(uiState.navigateToFailure) { + if (!uiState.navigateToFailure) return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + navigateToFailure() + } + + PurchaseSummaryScreen( + params = uiState.params, + isSubmitting = uiState.isSubmitting, + navigateUp = navigateUp, + onConfirm = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, + ) +} + +@Composable +private fun PurchaseSummaryScreen( + params: SummaryParameters, + isSubmitting: Boolean, + navigateUp: () -> Unit, + onConfirm: () -> Unit, +) { + HedvigScaffold(navigateUp) { + val offer = params.selectedOffer + HedvigCard(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + HedvigText( + text = params.productDisplayName, + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(8.dp)) + HedvigText( + text = offer.exposureDisplayName, + style = HedvigTheme.typography.bodySmall, + ) + if (offer.deductibleDisplayName != null) { + Spacer(Modifier.height(4.dp)) + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { + HedvigText( + text = "Sj\u00e4lvrisk", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + }, + spaceBetween = 8.dp, + endSlot = { + HedvigText( + text = offer.deductibleDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + }, + ) + } + Spacer(Modifier.height(16.dp)) + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { + HedvigText( + text = "Pris", + style = HedvigTheme.typography.bodySmall, + ) + }, + spaceBetween = 8.dp, + endSlot = { + if (offer.hasDiscount && offer.grossAmount != offer.netAmount) { + Row { + HedvigText( + text = "${offer.grossAmount.toInt()} ${offer.grossCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + textDecoration = TextDecoration.LineThrough, + ) + Spacer(Modifier.width(4.dp)) + HedvigText( + text = "${offer.netAmount.toInt()} ${offer.netCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + ) + } + } else { + HedvigText( + text = "${offer.netAmount.toInt()} ${offer.netCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + ) + } + }, + ) + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Signera med BankID", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = !isSubmitting, + isLoading = isSubmitting, + onClick = onConfirm, + ) + Spacer(Modifier.height(16.dp)) + } +} +``` + +- [ ] **Step 5: Move `SigningViewModel.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.sign + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.SigningStatus +import com.hedvig.android.feature.purchase.common.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import kotlinx.coroutines.delay + +class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = null, + bankIdOpened = false, + ), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), +) + +class SigningPresenter( + private val signingParameters: SigningParameters, + private val pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SigningUiState): SigningUiState { + var bankIdOpened by remember { mutableStateOf((lastState as? SigningUiState.Polling)?.bankIdOpened ?: false) } + var currentState by remember { mutableStateOf(lastState) } + + CollectEvents { event -> + when (event) { + SigningEvent.BankIdOpened -> { + bankIdOpened = true + } + + SigningEvent.ClearNavigation -> {} + } + } + + LaunchedEffect(Unit) { + while (true) { + pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( + ifLeft = { + currentState = SigningUiState.Failed + return@LaunchedEffect + }, + ifRight = { pollResult -> + when (pollResult.status) { + SigningStatus.SIGNED -> { + currentState = SigningUiState.Success(startDate = signingParameters.startDate) + return@LaunchedEffect + } + + SigningStatus.FAILED -> { + currentState = SigningUiState.Failed + return@LaunchedEffect + } + + SigningStatus.PENDING -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = pollResult.liveQrCodeData, + bankIdOpened = bankIdOpened, + ) + } + } + }, + ) + delay(2_000) + } + } + + return when (val state = currentState) { + is SigningUiState.Polling -> state.copy(bankIdOpened = bankIdOpened) + else -> currentState + } + } +} + +sealed interface SigningUiState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val liveQrCodeData: String?, + val bankIdOpened: Boolean, + ) : SigningUiState + + data class Success(val startDate: String?) : SigningUiState + + data object Failed : SigningUiState +} + +sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent +} +``` + +- [ ] **Step 6: Move `SigningDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/sign/SigningDestination.kt` + +Same as existing file but with package `com.hedvig.android.feature.purchase.common.ui.sign`, public visibility, and imports updated to reference `com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel`, etc. + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.sign + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun SigningDestination( + viewModel: SigningViewModel, + navigateToSuccess: (startDate: String?) -> Unit, + navigateToFailure: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val canOpenBankId = remember { canBankIdAppHandleUri(context) } + var hasNavigated by remember { mutableStateOf(false) } + + LaunchedEffect(uiState) { + if (hasNavigated) return@LaunchedEffect + when (val state = uiState) { + is SigningUiState.Success -> { + hasNavigated = true + navigateToSuccess(state.startDate) + } + is SigningUiState.Failed -> { + hasNavigated = true + navigateToFailure() + } + is SigningUiState.Polling -> {} + } + } + + when (val state = uiState) { + is SigningUiState.Polling -> { + if (canOpenBankId && !state.bankIdOpened) { + LaunchedEffect(Unit) { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + } + HedvigFullScreenCenterAlignedProgress() + } else if (!canOpenBankId) { + QrCodeSigningScreen( + liveQrCodeData = state.liveQrCodeData, + onOpenBankId = { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + }, + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } + } + + is SigningUiState.Success, + is SigningUiState.Failed, + -> HedvigFullScreenCenterAlignedProgress() + } +} + +@Composable +private fun QrCodeSigningScreen(liveQrCodeData: String?, onOpenBankId: () -> Unit) { + HedvigScaffold(navigateUp = {}) { + Spacer(Modifier.weight(1f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + HedvigText( + text = "Logga in med BankID", + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Skanna QR-koden med BankID-appen p\u00e5 en annan enhet", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(24.dp)) + if (liveQrCodeData != null) { + QRCode( + data = liveQrCodeData, + modifier = Modifier.size(200.dp), + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "\u00d6ppna BankID", + onClick = onOpenBankId, + enabled = true, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.weight(1f)) + } +} + +@Composable +private fun QRCode(data: String, modifier: Modifier = Modifier) { + var intSize: IntSize? by remember { mutableStateOf(null) } + val painter by produceState(ColorPainter(Color.Transparent), intSize, data) { + val size = intSize ?: return@produceState + val bitmapPainter: BitmapPainter = withContext(Dispatchers.Default) { + val bitMatrix: BitMatrix = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + size.width, + size.height, + ) + val bitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.RGB_565) + for (x in 0 until size.width) { + for (y in 0 until size.height) { + val color = if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE + bitmap.setPixel(x, y, color) + } + } + BitmapPainter(bitmap.asImageBitmap()) + } + value = bitmapPainter + } + Image( + painter, + contentDescription = "BankID QR code", + modifier.onSizeChanged { intSize = it }, + ) +} + +@SuppressLint("QueryPermissionsNeeded") +private fun canBankIdAppHandleUri(context: Context): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + BANK_ID_APP_PACKAGE_NAME, + PackageManager.PackageInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(BANK_ID_APP_PACKAGE_NAME, 0) + } + true + } catch (e: PackageManager.NameNotFoundException) { + logcat(LogPriority.INFO) { "BankID app not installed, will show QR code" } + false + } +} + +private const val BANK_ID_APP_PACKAGE_NAME = "com.bankid.bus" +``` + +- [ ] **Step 7: Move `PurchaseSuccessDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/success/PurchaseSuccessDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.success + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.TopAppBarActionType + +@Composable +fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + itemsColumnHorizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + HedvigText( + text = "Din f\u00f6rs\u00e4kring \u00e4r klar!", + style = HedvigTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + if (startDate != null) { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Startdatum: $startDate", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.weight(1f)) + HedvigButton( + text = "St\u00e4ng", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = true, + onClick = close, + ) + Spacer(Modifier.height(16.dp)) + } +} +``` + +- [ ] **Step 8: Move `PurchaseFailureDestination.kt`** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/failure/PurchaseFailureDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.ui.failure + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.TopAppBarActionType + +@Composable +fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + ) { + HedvigErrorSection( + onButtonClick = onRetry, + modifier = Modifier.weight(1f), + ) + } +} +``` + +- [ ] **Step 9: Create DI module for common** + +File: `app/feature/feature-purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/di/PurchaseCommonModule.kt` + +```kotlin +package com.hedvig.android.feature.purchase.common.di + +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.common.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.common.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val purchaseCommonModule = module { + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + SelectTierViewModel(params = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} +``` + +- [ ] **Step 10: Commit** + +```bash +git add app/feature/feature-purchase-common/ +git commit -m "feat: add shared screens, use cases, and DI to feature-purchase-common" +``` + +--- + +### Task 4: Update `feature-purchase-apartment` to use common module + +**Files:** +- Modify: `app/feature/feature-purchase-apartment/build.gradle.kts` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt` +- Modify: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt` +- Delete: All shared files that moved to common (offer/, summary/, sign/, success/, failure/ directories, AddToCartAndStartSignUseCase, PollSigningStatusUseCase, shared GraphQL files) + +- [ ] **Step 1: Add common dependency to build.gradle.kts** + +Add `implementation(projects.featurePurchaseCommon)` to dependencies. Remove `libs.zXing` (now in common). + +- [ ] **Step 2: Simplify `ApartmentPurchaseDestination.kt`** + +Remove all shared types (TierOfferData, SelectTierParameters, SummaryParameters, SigningParameters) — these now come from common. Keep only ApartmentPurchaseGraphDestination and internal apartment-specific destinations (Form only, plus re-exports from common for nav graph use). + +New contents: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination +} +``` + +- [ ] **Step 3: Update `ApartmentPurchaseNavGraph.kt`** + +Replace all apartment-specific imports for shared screens/models with imports from `com.hedvig.android.feature.purchase.common.*`. The nav graph now uses `PurchaseCommonDestination.*` for SelectTier, Summary, Signing, Success, Failure destinations, and `TierOfferData`, `SelectTierParameters`, `SummaryParameters`, `SigningParameters` from common navigation. + +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.apartmentPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = ApartmentPurchaseDestination.Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: ApartmentFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + ApartmentFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination(Signing) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SigningViewModel = koinViewModel { + parametersOf(route.params) + } + SigningDestination( + viewModel = viewModel, + navigateToSuccess = { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + close = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) finishApp() + }, + ) + } + } + // NOTE: Success destination is registered once in HedvigNavHost, not here. + // Both apartment and car nav graphs navigate to the same PurchaseCommonDestination.Success. +} +``` + +- [ ] **Step 4: Update `ApartmentPurchaseModule.kt`** + +Remove shared use cases and shared ViewModels (they're now in `purchaseCommonModule`). Keep only apartment-specific ones: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.di + +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } +} +``` + +- [ ] **Step 5: Update `PurchaseApartmentModels.kt`** + +Remove `SigningStart`, `SigningPollResult`, `SigningStatus` (now in common). Keep only apartment-specific models: + +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +data class ApartmentTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) +``` + +- [ ] **Step 6: Delete shared files from apartment module** + +Delete these files/directories that moved to common: +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/` (entire directory) +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt` +- `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql` +- `app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql` + +- [ ] **Step 7: Verify apartment build compiles** + +Run: `./gradlew :app:feature:feature-purchase-apartment:compileDebugKotlin :app:feature:feature-purchase-common:compileDebugKotlin` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 8: Commit** + +```bash +git add -A app/feature/feature-purchase-apartment/ app/feature/feature-purchase-common/ +git commit -m "refactor: migrate apartment purchase to use feature-purchase-common" +``` + +--- + +### Task 5: Create `feature-purchase-car` module + +**Files:** +- Create: `app/feature/feature-purchase-car/build.gradle.kts` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt` +- Create: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt` + +- [ ] **Step 1: Create `build.gradle.kts`** + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.featurePurchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} +``` + +- [ ] **Step 2: Create GraphQL operations** + +File: `app/feature/feature-purchase-car/src/main/graphql/CarShopSessionCreateMutation.graphql` +```graphql +mutation CarShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentCreateMutation.graphql` +```graphql +mutation CarPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentDataUpdateMutation.graphql` +```graphql +mutation CarPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} +``` + +File: `app/feature/feature-purchase-car/src/main/graphql/CarPriceIntentConfirmMutation.graphql` +```graphql +mutation CarPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...PurchaseProductOfferFragment + } + } + userError { + message + } + } +} +``` + +Note: `PurchaseProductOfferFragment` is defined in `feature-purchase-common`. The car module depends on common, so Apollo will find this fragment via the dependency. + +- [ ] **Step 3: Create data models** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CarPurchaseModels.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class CarOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class CarTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) +``` + +- [ ] **Step 4: Create `CreateCarSessionAndPriceIntentUseCase`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/CreateCarSessionAndPriceIntentUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.CarPriceIntentCreateMutation +import octopus.CarShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateCarSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateCarSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateCarSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(CarShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(CarPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} +``` + +- [ ] **Step 5: Create `SubmitCarFormAndGetOffersUseCase`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/data/SubmitCarFormAndGetOffersUseCase.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.CarPriceIntentConfirmMutation +import octopus.CarPriceIntentDataUpdateMutation +import octopus.fragment.PurchaseProductOfferFragment + +internal interface SubmitCarFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either +} + +internal class SubmitCarFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitCarFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + registrationNumber: String, + mileage: Int, + street: String, + zipCode: String, + email: String, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("registrationNumber", registrationNumber) + put("mileage", mileage) + put("street", street) + put("zipCode", zipCode) + put("email", email) + } + + val updateResult = apolloClient + .mutation(CarPriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(CarPriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + CarOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +private fun PurchaseProductOfferFragment.toTierOffer(): CarTierOffer { + return CarTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} +``` + +- [ ] **Step 6: Create `CarFormViewModel`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormViewModel.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.ui.form + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.car.data.CarOffers +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.SessionAndIntent +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class CarFormViewModel( + productName: String, + createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = CarFormState(), + presenter = CarFormPresenter(productName, createCarSessionAndPriceIntentUseCase, submitCarFormAndGetOffersUseCase), +) + +internal sealed interface CarFormEvent { + data class SubmitForm( + val ssn: String, + val registrationNumber: String, + val mileage: Int?, + val street: String, + val zipCode: String, + val email: String, + ) : CarFormEvent + + data object ClearNavigation : CarFormEvent + data object Retry : CarFormEvent +} + +internal data class CarFormState( + val ssnError: String? = null, + val registrationNumberError: String? = null, + val mileageError: String? = null, + val streetError: String? = null, + val zipCodeError: String? = null, + val emailError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: CarOffersNavigationData? = null, +) + +internal data class CarOffersNavigationData( + val shopSessionId: String, + val offers: CarOffers, +) + +private class CarFormPresenter( + private val productName: String, + private val createCarSessionAndPriceIntentUseCase: CreateCarSessionAndPriceIntentUseCase, + private val submitCarFormAndGetOffersUseCase: SubmitCarFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: CarFormState): CarFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: CarFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is CarFormEvent.SubmitForm -> { + val errors = validate(event) + if (errors.hasErrors()) { + currentState = currentState.copy( + ssnError = errors.ssnError, + registrationNumberError = errors.registrationNumberError, + mileageError = errors.mileageError, + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + emailError = errors.emailError, + ) + } else { + currentState = currentState.copy( + ssnError = null, + registrationNumberError = null, + mileageError = null, + streetError = null, + zipCodeError = null, + emailError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + CarFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + CarFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createCarSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitCarFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = submit.ssn, + registrationNumber = submit.registrationNumber.replace(" ", ""), + mileage = submit.mileage!!, + street = submit.street, + zipCode = submit.zipCode, + email = submit.email, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "N\u00e5got gick fel", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = CarOffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val ssnError: String?, + val registrationNumberError: String?, + val mileageError: String?, + val streetError: String?, + val zipCodeError: String?, + val emailError: String?, +) { + fun hasErrors(): Boolean = ssnError != null || registrationNumberError != null || + mileageError != null || streetError != null || zipCodeError != null || emailError != null +} + +private val SSN_REGEX = Regex("^\\d{12}$") +private val REG_NUMBER_REGEX = Regex("^[A-Za-z]{3}\\s?\\d{2}[A-Za-z0-9]$") +private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + +private fun validate(form: CarFormEvent.SubmitForm): ValidationErrors { + return ValidationErrors( + ssnError = when { + form.ssn.isBlank() -> "Ange personnummer" + !SSN_REGEX.matches(form.ssn) -> "Ange ett giltigt personnummer (12 siffror)" + else -> null + }, + registrationNumberError = when { + form.registrationNumber.isBlank() -> "Ange registreringsnummer" + !REG_NUMBER_REGEX.matches(form.registrationNumber.replace(" ", "").let { + if (it.length >= 3) it.substring(0, 3) + " " + it.substring(3) else it + }) -> "Ange ett giltigt registreringsnummer (t.ex. ABC 123)" + else -> null + }, + mileageError = if (form.mileage == null) "V\u00e4lj miltal" else null, + streetError = if (form.street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + form.zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !form.zipCode.all { it.isDigit() } -> "Postnumret f\u00e5r bara inneh\u00e5lla siffror" + else -> null + }, + emailError = when { + form.email.isBlank() -> "Ange e-postadress" + !EMAIL_REGEX.matches(form.email) -> "Ange en giltig e-postadress" + else -> null + }, + ) +} +``` + +- [ ] **Step 7: Create `CarFormDestination`** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/ui/form/CarFormDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.ui.form + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigDropdownField +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.car.data.CarOffers + +@Composable +internal fun CarFormDestination( + viewModel: CarFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: CarOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(CarFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Bilf\u00f6rs\u00e4kring", + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + + else -> { + var ssn by remember { mutableStateOf("") } + var registrationNumber by remember { mutableStateOf("") } + var selectedMileage: MileageOption? by remember { mutableStateOf(null) } + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + + CarFormContent( + ssn = ssn, + registrationNumber = registrationNumber, + selectedMileage = selectedMileage, + street = street, + zipCode = zipCode, + email = email, + ssnError = uiState.ssnError, + registrationNumberError = uiState.registrationNumberError, + mileageError = uiState.mileageError, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + emailError = uiState.emailError, + isSubmitting = uiState.isSubmitting, + onSsnChanged = { value -> if (value.all { it.isDigit() } && value.length <= 12) ssn = value }, + onRegistrationNumberChanged = { value -> + val cleaned = value.uppercase().filter { it.isLetterOrDigit() } + registrationNumber = if (cleaned.length > 3) { + cleaned.substring(0, 3) + " " + cleaned.substring(3, minOf(cleaned.length, 6)) + } else { + cleaned + } + }, + onMileageSelected = { selectedMileage = it }, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onEmailChanged = { email = it }, + onSubmit = { + viewModel.emit( + CarFormEvent.SubmitForm( + ssn = ssn, + registrationNumber = registrationNumber, + mileage = selectedMileage?.value, + street = street, + zipCode = zipCode, + email = email, + ), + ) + }, + onRetry = { viewModel.emit(CarFormEvent.Retry) }, + ) + } + } + } +} + +internal enum class MileageOption(val value: Int, val label: String) { + M_1000(1000, "0 - 1 000 mil"), + M_1500(1500, "1 000 - 1 500 mil"), + M_2000(2000, "1 500 - 2 000 mil"), + M_2500(2500, "2 000 - 2 500 mil"), + M_2501(2501, "2 500+ mil"), +} + +@Composable +private fun CarFormContent( + ssn: String, + registrationNumber: String, + selectedMileage: MileageOption?, + street: String, + zipCode: String, + email: String, + ssnError: String?, + registrationNumberError: String?, + mileageError: String?, + streetError: String?, + zipCodeError: String?, + emailError: String?, + isSubmitting: Boolean, + onSsnChanged: (String) -> Unit, + onRegistrationNumberChanged: (String) -> Unit, + onMileageSelected: (MileageOption) -> Unit, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onSubmit: () -> Unit, + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Fyll i dina uppgifter s\u00e5 ber\u00e4knar vi ditt pris", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HedvigTextField( + text = ssn, + onValueChange = onSsnChanged, + labelText = "Personnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = ssnError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = registrationNumber, + onValueChange = onRegistrationNumberChanged, + labelText = "Registreringsnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = registrationNumberError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + var mileageExpanded by remember { mutableStateOf(false) } + HedvigDropdownField( + text = selectedMileage?.label ?: "", + labelText = "Miltal per \u00e5r", + onItemChosen = { index -> + onMileageSelected(MileageOption.entries[index]) + mileageExpanded = false + }, + items = MileageOption.entries.map { it.label }, + expanded = mileageExpanded, + onExpandedChange = { mileageExpanded = it }, + errorState = mileageError.toErrorState(), + enabled = !isSubmitting, + ) + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + HedvigTextField( + text = email, + onValueChange = onEmailChanged, + labelText = "E-post", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = emailError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + enabled = !isSubmitting, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Ber\u00e4kna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} +``` + +**Note:** `HedvigDropdownField` may not exist in the design system. If it doesn't, we'll need to check what dropdown component is available. The implementation agent should verify this and use whatever dropdown/exposed-dropdown-menu component the design system provides. If none exists, use a simple clickable text field that opens a bottom sheet or menu with the mileage options. + +- [ ] **Step 8: Create navigation destinations** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseDestination.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class CarPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface CarPurchaseDestination { + @Serializable + data object Form : CarPurchaseDestination, Destination +} +``` + +- [ ] **Step 9: Create navigation graph** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/navigation/CarPurchaseNavGraph.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.car.ui.form.CarFormDestination +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Failure +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.carPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = CarPurchaseDestination.Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: CarFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + CarFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination(Signing) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SigningViewModel = koinViewModel { + parametersOf(route.params) + } + SigningDestination( + viewModel = viewModel, + navigateToSuccess = { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + close = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) finishApp() + }, + ) + } + } + // NOTE: Success destination is registered once in HedvigNavHost, not here. + // Both apartment and car nav graphs navigate to the same PurchaseCommonDestination.Success. +} +``` + +- [ ] **Step 10: Create DI module** + +File: `app/feature/feature-purchase-car/src/main/kotlin/com/hedvig/android/feature/purchase/car/di/CarPurchaseModule.kt` + +```kotlin +package com.hedvig.android.feature.purchase.car.di + +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.car.data.CreateCarSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.car.data.SubmitCarFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.car.ui.form.CarFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val carPurchaseModule = module { + single { CreateCarSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitCarFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + CarFormViewModel( + productName = params.get(), + createCarSessionAndPriceIntentUseCase = get(), + submitCarFormAndGetOffersUseCase = get(), + ) + } +} +``` + +- [ ] **Step 11: Commit** + +```bash +git add app/feature/feature-purchase-car/ +git commit -m "feat: add feature-purchase-car module with form, use cases, and navigation" +``` + +--- + +### Task 6: Wire car purchase into app navigation and DI + +**Files:** +- Modify: `app/app/build.gradle.kts` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` +- Modify: `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt` +- Modify: `app/feature/feature-insurances/build.gradle.kts` (if needed for nav dependency) + +- [ ] **Step 1: Add dependencies to app build.gradle.kts** + +Add to `app/app/build.gradle.kts` dependencies: +```kotlin +implementation(projects.featurePurchaseCommon) +implementation(projects.featurePurchaseCar) +``` + +- [ ] **Step 2: Register modules in ApplicationModule** + +In `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt`, add imports and include the new modules: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.common.di.purchaseCommonModule +import com.hedvig.android.feature.purchase.car.di.carPurchaseModule +``` + +Add to the `includes(...)` block (near `apartmentPurchaseModule`): +```kotlin +purchaseCommonModule, +carPurchaseModule, +``` + +- [ ] **Step 3: Add car purchase nav graph to HedvigNavHost** + +In `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt`: + +Add imports: +```kotlin +import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseGraphDestination +import com.hedvig.android.feature.purchase.car.navigation.carPurchaseNavGraph +``` + +Add the nav graph call after `apartmentPurchaseNavGraph(...)`: +```kotlin +carPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, +) +``` + +Also add the shared `PurchaseCommonDestination.Success` destination registration (once, shared by both purchase flows): +```kotlin +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination +import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination + +// In the NavHost builder, after both purchase nav graphs: +navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.popBackStack()) finishApp() + }, + ) +} +``` + +Add car purchase navigation callback in the insurances graph setup. Find `onNavigateToApartmentPurchase` and add a new parameter: +```kotlin +onNavigateToCarPurchase = { productName -> + navController.navigate(CarPurchaseGraphDestination(productName)) +}, +``` + +- [ ] **Step 4: Update InsuranceGraph to route car cross-sells** + +In `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt`: + +Add `onNavigateToCarPurchase: (productName: String) -> Unit` parameter to `insuranceGraph()`. + +Update the `onCrossSellClick` callback (currently hardcoded at line 64-67): + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> + // TODO: Extract product name from cross-sell data and route accordingly + // For now, route apartment cross-sells to apartment and car cross-sells to car + onNavigateToApartmentPurchase("SE_APARTMENT_RENT") +}, +``` + +Note: The proper product routing (determining if a cross-sell is for apartment vs car based on the URL or cross-sell metadata) depends on how cross-sell data is structured. The implementing agent should check what data `onCrossSellClick` receives and route to the correct flow. For now, the car flow is wired up and can be tested directly via `CarPurchaseGraphDestination("SE_CAR")`. + +- [ ] **Step 5: Verify full build compiles** + +Run: `./gradlew :app:app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 6: Run ktlint formatting** + +Run: `./gradlew ktlintFormat` + +- [ ] **Step 7: Commit** + +```bash +git add app/app/ app/feature/feature-insurances/ +git commit -m "feat: wire car purchase flow into app navigation and DI" +``` + +--- + +### Task 7: Verify everything works end-to-end + +- [ ] **Step 1: Run full project build** + +Run: `./gradlew :app:app:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: Run tests for affected modules** + +Run: `./gradlew :app:feature:feature-purchase-common:test :app:feature:feature-purchase-apartment:test :app:feature:feature-purchase-car:test` +Expected: All tests pass (or no tests to run for new modules) + +- [ ] **Step 3: Run ktlint check** + +Run: `./gradlew ktlintCheck` +Expected: No violations + +- [ ] **Step 4: Commit any fixes** + +If any build or lint issues found, fix and commit. diff --git a/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md b/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md new file mode 100644 index 0000000000..b32006dc41 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-car-purchase-flow-design.md @@ -0,0 +1,158 @@ +# Car Purchase Flow - Design Spec + +## Goal + +Add in-app car insurance purchase flow, reusing the existing apartment purchase architecture. Extract shared purchase screens into a common module so both apartment and car flows share tier selection, summary, signing, success, and failure screens. + +## Module Structure + +### New modules + +#### `feature-purchase-common` + +Shared purchase screens, use cases, models, and GraphQL operations that are product-agnostic. + +**Screens:** +- `SelectTierDestination` + `SelectTierViewModel` — tier/deductible selection with grouped offers +- `PurchaseSummaryDestination` + `PurchaseSummaryViewModel` — selected offer details, triggers signing +- `SigningDestination` + `SigningViewModel` — BankID polling, QR code fallback +- `PurchaseSuccessDestination` — confirmation with optional start date +- `PurchaseFailureDestination` — error display with retry + +**Use cases:** +- `AddToCartAndStartSignUseCase` — calls `ShopSessionCartEntriesAdd` + `ShopSessionStartSign` +- `PollSigningStatusUseCase` — polls `ShopSessionSigning` with `NetworkOnly` fetch policy + +**Models:** +- `TierOfferData` — serializable offer data passed between screens (offerId, tier name/description, gross/net pricing, USPs, exposure, deductible, discount flag) +- `SelectTierParameters` — shopSessionId, offers list, productDisplayName +- `SummaryParameters` — shopSessionId, selectedOffer, productDisplayName +- `SigningParameters` — signingId, autoStartToken, startDate +- `SigningStart` — signingId, autoStartToken +- `SigningPollResult` — status, liveQrCodeData +- `SigningStatus` — enum: PENDING, SIGNED, FAILED + +**GraphQL operations (moved from apartment, drop `Apartment` prefix):** +- `ShopSessionCartEntriesAddMutation.graphql` +- `ShopSessionStartSignMutation.graphql` +- `ShopSessionSigningQuery.graphql` +- `ProductOfferFragment.graphql` + +**Build config:** +- Plugins: `hedvig.android.library`, `hedvig.gradle.plugin` +- Hedvig DSL: `apollo("octopus")`, `serialization()`, `compose()` +- Dependencies: same as current apartment module minus form-specific deps + +#### `feature-purchase-car` + +Car-specific form, use cases, navigation, and DI. + +**Screens:** +- `CarFormDestination` + `CarFormViewModel` — car insurance form + +**Form fields (matching racoon SE_CAR template):** +| Field | Type | Validation | Notes | +|-------|------|-----------|-------| +| SSN (personnummer) | Text, numeric | 12 digits (YYYYMMDDXXXX) | Swedish personal number | +| Registration number | Text | 3 letters + 2 digits + 1 alphanumeric (e.g. ABC 123) | Auto-uppercase, auto-space after 3rd char | +| Mileage | Dropdown | Required selection | Options: 1000, 1500, 2000, 2500, 2500+ (Scandinavian miles) | +| Street address | Text | Non-empty | | +| Zip code | Text, numeric | 5 digits | | +| Email | Text | Valid email format | | + +**Use cases:** +- `CreateCarSessionAndPriceIntentUseCase` — creates ShopSession + PriceIntent for `SE_CAR` +- `SubmitCarFormAndGetOffersUseCase` — submits form data map (`ssn`, `registrationNumber`, `mileage`, `street`, `zipCode`, `email`) via `PriceIntentDataUpdate`, then confirms via `PriceIntentConfirm` + +**GraphQL operations (car-prefixed for Apollo codegen isolation):** +- `CarShopSessionCreateMutation.graphql` — same schema as apartment, different operation name +- `CarPriceIntentCreateMutation.graphql` +- `CarPriceIntentDataUpdateMutation.graphql` +- `CarPriceIntentConfirmMutation.graphql` + +**Navigation:** +- `CarPurchaseGraphDestination(productName: String)` — entry point +- `CarPurchaseNavGraph` — wires: CarForm -> SelectTier -> Summary -> Signing -> Success/Failure +- All post-form destinations come from `feature-purchase-common` + +**DI:** +- `carPurchaseModule` — Koin module registering car-specific use cases and ViewModels + +### Modified modules + +#### `feature-purchase-apartment` + +Slimmed down — shared code extracted to common. + +**Keeps:** +- `ApartmentFormDestination` + `ApartmentFormViewModel` +- `CreateSessionAndPriceIntentUseCase` +- `SubmitFormAndGetOffersUseCase` +- `ApartmentPurchaseNavGraph` +- Apartment-specific GraphQL: `ShopSessionCreate`, `PriceIntentCreate`, `PriceIntentDataUpdate`, `PriceIntentConfirm` + +**Removes (moved to common):** +- `SelectTierDestination`, `SelectTierViewModel` +- `PurchaseSummaryDestination`, `PurchaseSummaryViewModel` +- `SigningDestination`, `SigningViewModel` +- `PurchaseSuccessDestination`, `PurchaseFailureDestination` +- `AddToCartAndStartSignUseCase`, `PollSigningStatusUseCase` +- Shared models (`TierOfferData`, `SigningParameters`, etc.) +- `ShopSessionCartEntriesAdd`, `ShopSessionStartSign`, `ShopSessionSigning`, `ProductOfferFragment` GraphQL files + +**Adds dependency on:** `feature-purchase-common` + +#### `feature-insurances` + +Update cross-sell routing to support both products: +- Currently hardcoded: `onNavigateToApartmentPurchase("SE_APARTMENT_RENT")` +- Add `onNavigateToCarPurchase: (productName: String) -> Unit` callback +- Route based on cross-sell product name prefix (`SE_APARTMENT_*` vs `SE_CAR*`) + +#### `app` (main application module) + +- Register `carPurchaseModule` in `ApplicationModule` +- Add `carPurchaseNavGraph()` call in `HedvigNavHost` +- Wire `onNavigateToCarPurchase` in insurances graph to navigate to `CarPurchaseGraphDestination` + +## Module Dependency Graph + +``` +feature-purchase-apartment ──> feature-purchase-common +feature-purchase-car ──────> feature-purchase-common +app ──> feature-purchase-apartment +app ──> feature-purchase-car +app ──> feature-purchase-common +``` + +Feature modules do not depend on each other — only on common (a library module). + +## Data Flow + +### Car Purchase Flow + +``` +1. User taps car cross-sell in insurances tab +2. Navigate to CarPurchaseGraphDestination(productName = "SE_CAR") +3. CarFormScreen loads: + a. CreateCarSessionAndPriceIntentUseCase creates ShopSession + PriceIntent + b. User fills: SSN, registration number, mileage, street, zip, email + c. SubmitCarFormAndGetOffersUseCase sends PriceIntentDataUpdate + PriceIntentConfirm + d. Returns list of TierOffer (mapped from ProductOfferFragment) +4. SelectTierScreen (shared): user picks coverage tier + deductible +5. PurchaseSummaryScreen (shared): user reviews and confirms +6. SigningScreen (shared): BankID signing with polling +7. PurchaseSuccessScreen (shared): confirmation +``` + +## Key Design Decisions + +1. **Separate GraphQL operation names per product** — Apollo generates Kotlin classes per operation name. Even though the mutations are identical in schema, each product module gets its own prefixed operations to avoid classpath conflicts. + +2. **Common module is a library, not a feature** — This lets both feature modules depend on it without violating the "features can't depend on features" build rule. + +3. **Form data is a `Map`** — The `PriceIntentDataUpdate` mutation accepts `PricingFormData!` which is a JSON scalar. Each product builds its own map with different keys. No shared form abstraction needed. + +4. **Shared screens are product-agnostic** — Tier selection, summary, signing, success, and failure screens operate purely on `TierOfferData`, `SummaryParameters`, and `SigningParameters`. They have no knowledge of apartment vs car. + +5. **Mileage as dropdown** — Matches racoon's CarMileageField implementation with fixed options (1000, 1500, 2000, 2500, 2500+) in Scandinavian miles. diff --git a/gradle.properties b/gradle.properties index 3f95d650a4..d673132631 100644 --- a/gradle.properties +++ b/gradle.properties @@ -39,4 +39,7 @@ android.dependency.excludeLibraryComponentsFromConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false android.builtInKotlin=false -android.newDsl=false \ No newline at end of file +android.newDsl=false + +# Compose Preview screenshot tests (AGP). Required for the `com.android.compose.screenshot` plugin. +android.experimental.enableScreenshotTest=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9e5952f56..a51dac5d18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ lintGradlePlugin = "9.0.0" # gradlePlugin versions androidGradlePlugin = "9.0.0" +composeScreenshot = "0.0.1-alpha15" apollo = "4.4.1" apolloAdapters = "0.7.0" apolloEngineKtor = "0.1.1" @@ -112,6 +113,7 @@ androidx-compose-materialIconsCore = { module = "androidx.compose.material:mater androidx-compose-materialRipple = { module = "androidx.compose.material:material-ripple" } androidx-compose-uiTestManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-ui-alpha" } androidx-compose-uiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-ui-alpha" } +composeScreenshotValidationApi = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } androidx-compose-uiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-ui-alpha" } androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "androidx-datastore" } androidx-datastore-preferencesCore = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx-datastore" } @@ -270,6 +272,7 @@ appIconBannerGenerator = { id = "com.starter.easylauncher", version.ref = "easyl cacheFix = { id = "org.gradle.android.cache-fix", version.ref = "cacheFix" } composeJetbrainsCompilerGradlePlugin = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose" } composeKotlinCompilerGradlePlugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +composeScreenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" } crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" } datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin", version.ref = "datadogPlugin" } dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }