Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b272803
chore: add design spec for in-app apartment purchase flow
hugokallstrom Mar 31, 2026
7445720
chore: add implementation plan for in-app apartment purchase flow
hugokallstrom Mar 31, 2026
994801e
feat: scaffold feature-purchase-apartment module with GraphQL operations
hugokallstrom Mar 31, 2026
8df76d6
feat: add domain models and use cases for apartment purchase
hugokallstrom Mar 31, 2026
c7fa06e
feat: add apartment purchase form screen with validation
hugokallstrom Mar 31, 2026
2c01154
feat: add tier selection screen for apartment purchase
hugokallstrom Mar 31, 2026
496d02f
feat: add summary, signing, success, and failure screens
hugokallstrom Mar 31, 2026
72e128e
feat: add navigation graph and DI module for apartment purchase
hugokallstrom Mar 31, 2026
9594fb7
feat: integrate apartment purchase flow into main app navigation
hugokallstrom Mar 31, 2026
3ac8b52
chore: fix ktlint formatting in apartment purchase module
hugokallstrom Mar 31, 2026
329f545
feat: route apartment cross-sells to in-app purchase flow
hugokallstrom Mar 31, 2026
121fc12
fix: remove nested verticalScroll causing crash in form screen
hugokallstrom Mar 31, 2026
b81e6ad
fix: rewrite tier selection to use HedvigScaffold instead of manual s…
hugokallstrom Mar 31, 2026
fb26205
feat: add Compose previews to all purchase apartment screens
hugokallstrom Mar 31, 2026
4fa6fa2
fix: move text field state to UI layer to prevent cursor jumping
hugokallstrom Mar 31, 2026
cc85580
fix: use Locale constructor instead of Locale.of (not available on An…
hugokallstrom Mar 31, 2026
fc45cfe
fix: add remember to mutableStateOf in SelectTierPresenter
hugokallstrom Mar 31, 2026
b3a6ab7
feat: add BankID QR code fallback when app is not installed
hugokallstrom Mar 31, 2026
a6fcb30
fix: resolve signing navigation, group tiers by deductible, improve f…
hugokallstrom Mar 31, 2026
a91b17e
fix: fix BankID signing - poll immediately, skip cache, robust naviga…
hugokallstrom Mar 31, 2026
34c5e60
fix: add apollo-normalizedCache dep and remove unused import
hugokallstrom Mar 31, 2026
7186aa9
fix: success screen returns to insurance tab instead of closing app
hugokallstrom Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ dependencies {
implementation(projects.designSystemHedvig)
implementation(projects.designSystemInternals)
implementation(projects.featureAddonPurchase)
implementation(projects.featurePurchaseApartment)
implementation(projects.featureChat)
implementation(projects.featureChooseTier)
implementation(projects.featureClaimChat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import com.hedvig.android.datadog.core.di.datadogModule
import com.hedvig.android.datadog.demo.tracking.di.datadogDemoTrackingModule
import com.hedvig.android.design.system.hedvig.pdfrenderer.PdfDecoder
import com.hedvig.android.feature.addon.purchase.di.addonPurchaseModule
import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule
import com.hedvig.android.feature.change.tier.di.chooseTierModule
import com.hedvig.android.feature.chat.di.chatModule
import com.hedvig.android.feature.claim.details.di.claimDetailsModule
Expand Down Expand Up @@ -290,6 +291,7 @@ val applicationModule = module {
listOf(
addonPurchaseModule,
addonRemovalModule,
apartmentPurchaseModule,
androidPermissionModule,
apolloAuthListenersModule,
appModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ import com.hedvig.android.navigation.common.Destination
import com.hedvig.android.navigation.compose.typedPopBackStack
import com.hedvig.android.navigation.compose.typedPopUpTo
import com.hedvig.android.navigation.core.HedvigDeepLinkContainer
import org.koin.mp.KoinPlatform
import com.hedvig.feature.claim.chat.ClaimChatDestination
import com.hedvig.feature.claim.chat.claimChatGraph
import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository
import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination
import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph
import com.hedvig.feature.remove.addons.AddonRemoveGraphDestination
import com.hedvig.feature.remove.addons.removeAddonsNavGraph

Expand Down Expand Up @@ -135,6 +139,8 @@ internal fun HedvigNavHost(
hedvigAppState.navController.navigate(ImageViewer(imageUrl, cacheKey))
}

val crossSellAfterFlowRepository = KoinPlatform.getKoin().get<CrossSellAfterFlowRepository>()

NavHost(
navController = navController,
startDestination = HomeDestination.Graph::class,
Expand Down Expand Up @@ -318,6 +324,9 @@ internal fun HedvigNavHost(
),
)
},
onNavigateToApartmentPurchase = { productName ->
navController.navigate(ApartmentPurchaseGraphDestination(productName))
},
)
foreverGraph(
hedvigDeepLinkContainer = hedvigDeepLinkContainer,
Expand Down Expand Up @@ -473,6 +482,12 @@ internal fun HedvigNavHost(
tryToDialPhone = externalNavigator::tryToDialPhone,
)
imageViewerGraph(navController, imageLoader)
apartmentPurchaseNavGraph(
navController = navController,
popBackStack = popBackStackOrFinish,
finishApp = finishApp,
crossSellAfterFlowRepository = crossSellAfterFlowRepository,
)
removeAddonsNavGraph(
navController = hedvigAppState.navController,
onNavigateToNewConversation = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ sealed class CrossSellInfoType() {
override val loggableName: String = "moveFlow"
override val extraInfo: Map<String, Any?>? = null
}

data object Purchase : CrossSellInfoType() {
override val loggableName: String = "purchase"
override val extraInfo: Map<String, Any?>? = null
}
}

class CrossSellAfterFlowRepositoryImpl() : CrossSellAfterFlowRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ internal fun CrossSellInfoType.toCrossSellSource(): CrossSellInput {
is CrossSellInfoType.ClosedClaim -> smartCrossSellInput(FlowSource.CLOSED_CLAIM)
CrossSellInfoType.EditCoInsured -> smartCrossSellInput(FlowSource.EDIT_COINSURED)
CrossSellInfoType.MovingFlow -> smartCrossSellInput(FlowSource.MOVING)
CrossSellInfoType.Purchase -> smartCrossSellInput(FlowSource.UNKNOWN__)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.hedvig.android.navigation.compose.navDeepLinks
import com.hedvig.android.navigation.compose.navdestination
import com.hedvig.android.navigation.compose.navgraph
import com.hedvig.android.navigation.core.HedvigDeepLinkContainer
import java.net.URLDecoder
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf

Expand All @@ -40,6 +41,7 @@ fun NavGraphBuilder.insuranceGraph(
onNavigateToAddonPurchaseFlow: (List<ContractId>, AvailableAddon?) -> Unit,
onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit,
navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit,
onNavigateToApartmentPurchase: (productName: String) -> Unit,
) {
navgraph<InsurancesDestination.Graph>(
startDestination = InsurancesDestination.Insurances::class,
Expand All @@ -59,7 +61,10 @@ fun NavGraphBuilder.insuranceGraph(
onInsuranceCardClick = dropUnlessResumed { contractId: String ->
navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId))
},
onCrossSellClick = dropUnlessResumed { url: String -> openUrl(url) },
onCrossSellClick = dropUnlessResumed { url: String ->
// Hardcoded for testing: route all cross-sells to in-app purchase
onNavigateToApartmentPurchase("SE_APARTMENT_RENT")
},
navigateToCancelledInsurances = dropUnlessResumed {
navController.navigate(InsurancesDestinations.TerminatedInsurances)
},
Expand Down Expand Up @@ -117,3 +122,17 @@ fun NavGraphBuilder.insuranceGraph(
}
}
}

private fun parseApartmentProductFromUrl(url: String): String? {
val decodedUrl = try {
URLDecoder.decode(url, "UTF-8")
} catch (_: Exception) {
url
}
val lowerUrl = decodedUrl.lowercase()
return when {
lowerUrl.contains("hyresratt") || lowerUrl.contains("home-insurance/rental") -> "SE_APARTMENT_RENT"
lowerUrl.contains("bostadsratt") || lowerUrl.contains("home-insurance/homeowner") -> "SE_APARTMENT_BRF"
else -> null
}
}
53 changes: 53 additions & 0 deletions app/feature/feature-purchase-apartment/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
plugins {
id("hedvig.android.library")
id("hedvig.gradle.plugin")
}

hedvig {
apollo("octopus")
serialization()
compose()
}

android {
testOptions.unitTests.isReturnDefaultValues = true
}

dependencies {
api(libs.androidx.navigation.common)

implementation(libs.androidx.navigation.compose)
implementation(libs.apollo.normalizedCache)
implementation(libs.arrow.core)
implementation(libs.arrow.fx)
implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.koin.composeViewModel)
implementation(libs.koin.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.zXing)
implementation(projects.apolloCore)
implementation(projects.apolloOctopusPublic)
implementation(projects.composeUi)
implementation(projects.coreCommonPublic)
implementation(projects.coreResources)
implementation(projects.coreUiData)
implementation(projects.dataCrossSellAfterFlow)
implementation(projects.designSystemHedvig)
implementation(projects.languageCore)
implementation(projects.moleculePublic)
implementation(projects.navigationCommon)
implementation(projects.navigationCompose)
implementation(projects.navigationComposeTyped)
implementation(projects.navigationCore)

testImplementation(libs.apollo.testingSupport)
testImplementation(libs.assertK)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
testImplementation(libs.turbine)
testImplementation(projects.apolloOctopusTest)
testImplementation(projects.apolloTest)
testImplementation(projects.coreCommonTest)
testImplementation(projects.loggingTest)
testImplementation(projects.moleculeTest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
mutation ApartmentPriceIntentConfirm($priceIntentId: UUID!) {
priceIntentConfirm(priceIntentId: $priceIntentId) {
priceIntent {
id
offers {
...ApartmentProductOfferFragment
}
}
userError {
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mutation ApartmentPriceIntentCreate($shopSessionId: UUID!, $productName: String!) {
priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) {
id
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
mutation ApartmentPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) {
priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) {
priceIntent {
id
}
userError {
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
fragment ApartmentProductOfferFragment on ProductOffer {
id
variant {
displayName
displayNameSubtype
displayNameTier
tierDescription
typeOfContract
perils {
title
description
colorCode
covered
info
}
documents {
type
displayName
url
}
}
cost {
gross {
...MoneyFragment
}
net {
...MoneyFragment
}
discountsV2 {
amount {
...MoneyFragment
}
}
}
startDate
deductible {
displayName
amount
}
usps
exposure {
displayNameShort
}
bundleDiscount {
isEligible
potentialYearlySavings {
...MoneyFragment
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) {
shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) {
shopSession {
id
}
userError {
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mutation ApartmentShopSessionCreate($countryCode: CountryCode!) {
shopSessionCreate(input: { countryCode: $countryCode }) {
id
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
query ApartmentShopSessionSigning($signingId: UUID!) {
shopSessionSigning(id: $signingId) {
id
status
seBankidProperties {
autoStartToken
liveQrCodeData
bankidAppOpened
}
completion {
authorizationCode
}
userError {
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
mutation ApartmentStartSign($shopSessionId: UUID!) {
shopSessionStartSign(shopSessionId: $shopSessionId) {
signing {
id
status
seBankidProperties {
autoStartToken
liveQrCodeData
bankidAppOpened
}
userError {
message
}
}
userError {
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.hedvig.android.feature.purchase.apartment.data

import arrow.core.Either
import arrow.core.raise.either
import com.apollographql.apollo.ApolloClient
import com.hedvig.android.apollo.safeExecute
import com.hedvig.android.core.common.ErrorMessage
import com.hedvig.android.logger.LogPriority
import com.hedvig.android.logger.logcat
import octopus.ApartmentCartEntriesAddMutation
import octopus.ApartmentStartSignMutation

internal interface AddToCartAndStartSignUseCase {
suspend fun invoke(shopSessionId: String, offerId: String): Either<ErrorMessage, SigningStart>
}

internal class AddToCartAndStartSignUseCaseImpl(
private val apolloClient: ApolloClient,
) : AddToCartAndStartSignUseCase {
override suspend fun invoke(shopSessionId: String, offerId: String): Either<ErrorMessage, SigningStart> {
return either {
val cartResult = apolloClient
.mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId)))
.safeExecute()
.fold(
ifLeft = {
logcat(LogPriority.ERROR) { "Failed to add to cart: $it" }
raise(ErrorMessage())
},
ifRight = { it.shopSessionCartEntriesAdd },
)

if (cartResult.userError != null) {
raise(ErrorMessage(cartResult.userError?.message))
}

val signResult = apolloClient
.mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId))
.safeExecute()
.fold(
ifLeft = {
logcat(LogPriority.ERROR) { "Failed to start signing: $it" }
raise(ErrorMessage())
},
ifRight = { it.shopSessionStartSign },
)

if (signResult.userError != null) {
raise(ErrorMessage(signResult.userError?.message))
}

val signing = signResult.signing ?: run {
logcat(LogPriority.ERROR) { "No signing session returned" }
raise(ErrorMessage())
}

val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run {
logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" }
raise(ErrorMessage())
}

SigningStart(
signingId = signing.id,
autoStartToken = autoStartToken,
)
}
}
}
Loading
Loading