From 516746075e8a7d792331269c4d0e88cf5fa20d55 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:30:35 +0200 Subject: [PATCH 1/6] analytics: add CrossSellImpressionTracker for the cross_sell_shown event Introduce a shared CrossSellImpressionTracker (fun interface + Metro @ContributesBinding) in ui/cross-sells that fires the Firebase "cross_sell_shown" event once per offer, with user_flow, cross_sell_type, offer_id and (for the after-flow surface) flow_source. Add the crossSellSheetShown helper (mirrors the sheet's addon-over-cross-sell render priority) and an optional per-item onCrossSellImpression callback on CrossSellsSection, backed by Modifier.onVisibilityChanged so inline lists can report impressions on scroll-into-view. The callback defaults to null, so the sheets that reuse CrossSellsSection are unaffected. --- app/ui/cross-sells/build.gradle.kts | 1 + .../crosssells/CrossSellImpressionTracker.kt | 100 ++++++++++++++++++ .../hedvig/android/crosssells/CrossSells.kt | 59 +++++++---- 3 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt diff --git a/app/ui/cross-sells/build.gradle.kts b/app/ui/cross-sells/build.gradle.kts index 05d6c95e2c..373c30888b 100644 --- a/app/ui/cross-sells/build.gradle.kts +++ b/app/ui/cross-sells/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.jetbrains.compose.ui) implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(projects.composeUi) + implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(projects.coreUiData) implementation(projects.dataAddons) diff --git a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt new file mode 100644 index 0000000000..d93c80ed45 --- /dev/null +++ b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt @@ -0,0 +1,100 @@ +package com.hedvig.android.crosssells + +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.core.tracking.EventTrackingClient +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +/** + * Fires the `cross_sell_shown` analytics event, once for each cross-sell offer surfaced to a member. + * + * Call [crossSellShown] per individual offer that becomes visible, so a sheet or list showing several offers + * produces several events. [CrossSellUserFlow] records which surface the offer appeared on; consumers segment on it + * because the surfaces fire at very different rates (the insurances tab far more often than the after-flow sheet). + */ +fun interface CrossSellImpressionTracker { + fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) +} + +/** + * Fires one impression per offer shown in a [CrossSellSheetData], mirroring the sheet's render priority: an addon + * recommendation takes the primary slot over a cross-sell recommendation, and every [CrossSellSheetData.otherCrossSells] + * entry is a new-insurance offer. Used by the after-flow and home sheets, which render the same data. + */ +fun CrossSellImpressionTracker.crossSellSheetShown( + data: CrossSellSheetData, + userFlow: CrossSellUserFlow, + flowSource: CrossSellFlowSource? = null, +) { + when { + data.recommendedAddon != null -> { + crossSellShown(userFlow, CrossSellType.Addon, data.recommendedAddon.id, flowSource) + } + + data.recommendedCrossSell != null -> { + crossSellShown(userFlow, CrossSellType.NewPromise, data.recommendedCrossSell.crossSell.id, flowSource) + } + } + data.otherCrossSells.forEach { crossSell -> + crossSellShown(userFlow, CrossSellType.NewPromise, crossSell.id, flowSource) + } +} + +/** The surface a cross-sell offer was shown on. */ +enum class CrossSellUserFlow(val analyticsValue: String) { + SmartXSell("smart_x_sell"), + InsuranceCard("insurance_card"), + InsuranceScreen("insurance_screen"), + HomeScreen("home_screen"), +} + +/** Whether the offer is an add-on to an existing insurance or a new insurance. */ +enum class CrossSellType(val analyticsValue: String) { + Addon("addon"), + NewPromise("new_promise"), +} + +/** The self-service flow a member just completed when a [CrossSellUserFlow.SmartXSell] offer is shown. */ +enum class CrossSellFlowSource(val analyticsValue: String) { + ChangeTier("change_tier"), + ClosedClaim("closed_claim"), + Addon("addon"), + Moving("moving"), + EditCoInsured("edit_co_insured"), +} + +@ContributesBinding(AppScope::class) +@Inject +internal class CrossSellImpressionTrackerImpl( + private val eventTrackingClient: EventTrackingClient, +) : CrossSellImpressionTracker { + override fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) { + eventTrackingClient.trackEvent( + name = CROSS_SELL_SHOWN_EVENT, + parameters = buildMap { + put("user_flow", userFlow.analyticsValue) + put("cross_sell_type", crossSellType.analyticsValue) + put("offer_id", offerId) + if (flowSource != null) { + put("flow_source", flowSource.analyticsValue) + } + }, + ) + } +} + +private const val CROSS_SELL_SHOWN_EVENT = "cross_sell_shown" + +/** An offer counts as shown once at least half of it has been on screen for this long. */ +const val CROSS_SELL_IMPRESSION_MIN_DURATION_MS = 500L +const val CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE = 0.5f diff --git a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt index f6546b48f3..07b10a47a9 100644 --- a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt +++ b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -612,35 +613,47 @@ fun CrossSellsSection( title: String? = null, buttonSize: ButtonDefaults.ButtonSize = ButtonDefaults.ButtonSize.Medium, buttonShape: Shape? = null, + onCrossSellImpression: ((CrossSell) -> Unit)? = null, ) { Column(modifier) { if (withSubHeader) { CrossSellsSubHeaderWithDivider(title) } for ((index, crossSell) in crossSells.withIndex()) { - if (hasCrossSellDiscounts) { - CrossSellItemWithDiscounts( - crossSellTitle = crossSell.title, - crossSellSubtitle = crossSell.subtitle, - storeUrl = crossSell.storeUrl, - onCrossSellClick = onCrossSellClick, - isLoading = false, - imageLoader = imageLoader, - crossSellImageAsset = crossSell.pillowImage, - onSheetDismissed = onSheetDismissed, - buttonText = crossSell.buttonText, - buttonSize = buttonSize, - buttonShape = buttonShape, - ) - } else { - CrossSellItem( - crossSell, - onCrossSellClick, - onSheetDismissed = onSheetDismissed, - imageLoader = imageLoader, - buttonSize = buttonSize, - buttonShape = buttonShape, - ) + Box( + Modifier + .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onCrossSellImpression?.invoke(crossSell) + }, + ) { + if (hasCrossSellDiscounts) { + CrossSellItemWithDiscounts( + crossSellTitle = crossSell.title, + crossSellSubtitle = crossSell.subtitle, + storeUrl = crossSell.storeUrl, + onCrossSellClick = onCrossSellClick, + isLoading = false, + imageLoader = imageLoader, + crossSellImageAsset = crossSell.pillowImage, + onSheetDismissed = onSheetDismissed, + buttonText = crossSell.buttonText, + buttonSize = buttonSize, + buttonShape = buttonShape, + ) + } else { + CrossSellItem( + crossSell, + onCrossSellClick, + onSheetDismissed = onSheetDismissed, + imageLoader = imageLoader, + buttonSize = buttonSize, + buttonShape = buttonShape, + ) + } } if (index != crossSells.lastIndex) { Spacer(Modifier.height(16.dp)) From 38c5253f14c0c3a6521b2fc0203b670ccf28c34b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:30:44 +0200 Subject: [PATCH 2/6] analytics: track cross_sell_shown on the after-flow sheet Fire an impression per offer when the smart cross-sell sheet becomes visible, with user_flow=smart_x_sell and flow_source mapped from the CrossSellInfoType of the flow the member just completed. Add a presenter test asserting the per-offer events. --- .../sell/sheet/CrossSellSheetViewModel.kt | 28 ++++++- .../sell/sheet/CrossSellSheetPresenterTest.kt | 74 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt index 02d1384d24..9fa2af83c0 100644 --- a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt +++ b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt @@ -22,9 +22,13 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.core.demomode.DemoManager import com.hedvig.android.core.demomode.DemoSwitcher import com.hedvig.android.crosssells.BundleProgress +import com.hedvig.android.crosssells.CrossSellFlowSource +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell +import com.hedvig.android.crosssells.crossSellSheetShown import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository @@ -55,9 +59,10 @@ import octopus.type.UserFlow internal class CrossSellSheetViewModel( getCrossSellSheetDataUseCase: GetCrossSellSheetDataUseCase, crossSellAfterFlowRepository: CrossSellAfterFlowRepository, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( CrossSellSheetState.Loading, - CrossSellSheetPresenter(getCrossSellSheetDataUseCase, crossSellAfterFlowRepository), + CrossSellSheetPresenter(getCrossSellSheetDataUseCase, crossSellAfterFlowRepository, crossSellImpressionTracker), ) internal sealed interface CrossSellSheetEvent { @@ -77,6 +82,7 @@ internal sealed interface CrossSellSheetState { internal class CrossSellSheetPresenter( private val getCrossSellSheetDataUseCase: GetCrossSellSheetDataUseCase, private val crossSellAfterFlowRepository: CrossSellAfterFlowRepository, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -87,7 +93,15 @@ internal class CrossSellSheetPresenter( CollectEvents { event -> when (event) { is CrossSellSheetEvent.CrossSellSheetShown -> { - crossSellAfterFlowRepository.showedCrossSellSheet((state as? CrossSellSheetState.Content)?.infoType) + val content = state as? CrossSellSheetState.Content + crossSellAfterFlowRepository.showedCrossSellSheet(content?.infoType) + if (content != null) { + crossSellImpressionTracker.crossSellSheetShown( + data = content.crossSellSheetData, + userFlow = CrossSellUserFlow.SmartXSell, + flowSource = content.infoType.toFlowSource(), + ) + } } } } @@ -121,6 +135,16 @@ internal class CrossSellSheetPresenter( } } +internal fun CrossSellInfoType.toFlowSource(): CrossSellFlowSource { + return when (this) { + CrossSellInfoType.Addon -> CrossSellFlowSource.Addon + is CrossSellInfoType.ChangeTier -> CrossSellFlowSource.ChangeTier + is CrossSellInfoType.ClosedClaim -> CrossSellFlowSource.ClosedClaim + CrossSellInfoType.EditCoInsured -> CrossSellFlowSource.EditCoInsured + is CrossSellInfoType.MovingFlow -> CrossSellFlowSource.Moving + } +} + internal fun CrossSellInfoType.toCrossSellSource(): CrossSellInput { val smartCrossSellInput: (FlowSource) -> CrossSellInput = { flowSource -> CrossSellInput( diff --git a/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt b/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt index 8a89d9f9e5..72c5c38dbe 100644 --- a/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt +++ b/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt @@ -5,8 +5,14 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellFlowSource +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedAddon +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType import com.hedvig.android.molecule.test.test @@ -36,6 +42,7 @@ internal class CrossSellSheetPresenterTest { val presenter = CrossSellSheetPresenter( FakeGetCrossSellSheetDataUseCase(emptyData), FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + RecordingCrossSellImpressionTracker(), ) presenter.test(CrossSellSheetState.Loading) { @@ -50,6 +57,7 @@ internal class CrossSellSheetPresenterTest { val presenter = CrossSellSheetPresenter( FakeGetCrossSellSheetDataUseCase(addonData), FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + RecordingCrossSellImpressionTracker(), ) presenter.test(CrossSellSheetState.Loading) { @@ -59,6 +67,72 @@ internal class CrossSellSheetPresenterTest { ) } } + + @Test + fun `showing the sheet tracks an impression per shown offer`() = runTest { + val newPromise = CrossSell( + id = "cs1", + title = "Car", + subtitle = "Car insurance", + storeUrl = "url", + pillowImage = ImageAsset(id = "img", src = "src", description = "alt"), + ) + val data = CrossSellSheetData( + recommendedCrossSell = null, + otherCrossSells = listOf(newPromise), + recommendedAddon = recommendedAddon, + ) + val tracker = RecordingCrossSellImpressionTracker() + val presenter = CrossSellSheetPresenter( + FakeGetCrossSellSheetDataUseCase(data), + FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + tracker, + ) + + presenter.test(CrossSellSheetState.Loading) { + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.Loading) + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.Content(data, CrossSellInfoType.EditCoInsured)) + sendEvent(CrossSellSheetEvent.CrossSellSheetShown) + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.DontShow) + } + + assertThat(tracker.shown).isEqualTo( + listOf( + RecordingCrossSellImpressionTracker.Impression( + CrossSellUserFlow.SmartXSell, + CrossSellType.Addon, + "addonId", + CrossSellFlowSource.EditCoInsured, + ), + RecordingCrossSellImpressionTracker.Impression( + CrossSellUserFlow.SmartXSell, + CrossSellType.NewPromise, + "cs1", + CrossSellFlowSource.EditCoInsured, + ), + ), + ) + } +} + +private class RecordingCrossSellImpressionTracker : CrossSellImpressionTracker { + data class Impression( + val userFlow: CrossSellUserFlow, + val crossSellType: CrossSellType, + val offerId: String, + val flowSource: CrossSellFlowSource?, + ) + + val shown = mutableListOf() + + override fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) { + shown += Impression(userFlow, crossSellType, offerId, flowSource) + } } private class FakeGetCrossSellSheetDataUseCase( From a6d27a91f03b8482b4f6d8d4a03bc0484c0738c5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:30:54 +0200 Subject: [PATCH 3/6] analytics: track cross_sell_shown on the home screen Track both home cross-sell surfaces distinctly: the toolbar bottom sheet as user_flow=insurance_card (per offer when it becomes visible), and the inline home sections (Offers, Discover insurances, Addons) as user_flow=home_screen via onVisibilityChanged, deduped per screen visit in the presenter. --- .../feature/home/home/ui/HomeDestination.kt | 47 ++++++++++++++++++- .../feature/home/home/ui/HomePresenter.kt | 26 ++++++++++ .../feature/home/home/ui/HomeViewModel.kt | 3 ++ .../feature/home/home/ui/HomePresenterTest.kt | 19 ++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index b49b8f11b3..88e3918423 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -89,8 +90,11 @@ import com.google.accompanist.permissions.isGranted import com.hedvig.android.compose.ui.plus import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterProvider import com.hedvig.android.crosssells.BundleProgress +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_DURATION_MS +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE import com.hedvig.android.crosssells.CrossSellBottomSheet import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType import com.hedvig.android.crosssells.CrossSellsSection import com.hedvig.android.crosssells.PillowRow import com.hedvig.android.crosssells.RecommendedCrossSell @@ -248,6 +252,14 @@ internal fun HomeDestination( deleteDraftClaim = { draftId -> viewModel.emit(HomeEvent.DeleteDraftClaim(draftId)) }, navigateToFirstVet = navigateToFirstVet, markCrossSellsNotificationAsSeen = { viewModel.emit(HomeEvent.MarkCardCrossSellsAsSeen) }, + onCrossSellsShown = { viewModel.emit(HomeEvent.CrossSellsShown(it)) }, + onHomeCrossSellImpression = { crossSell -> + viewModel.emit(HomeEvent.HomeCrossSellImpression(crossSell.id, CrossSellType.NewPromise)) + }, + onHomeAddonImpression = { bannerInfo -> + // Addon banners carry no per-offer id, so the flow type identifies the addon offer. + viewModel.emit(HomeEvent.HomeCrossSellImpression(bannerInfo.flowType.name, CrossSellType.Addon)) + }, navigateToContactInfo = navigateToContactInfo, navigateToChipIdScreen = navigateToChipId, setEpochDayWhenLastToolTipShown = { epochDay -> @@ -282,6 +294,9 @@ private fun HomeScreen( navigateToContactInfo: () -> Unit, navigateToChipIdScreen: () -> Unit, markCrossSellsNotificationAsSeen: () -> Unit, + onCrossSellsShown: (CrossSellSheetData) -> Unit, + onHomeCrossSellImpression: (CrossSell) -> Unit = {}, + onHomeAddonImpression: (AddonBannerInfo) -> Unit = {}, setEpochDayWhenLastToolTipShown: (Long) -> Unit, imageLoader: ImageLoader, navigateToTravelCertificate: () -> Unit, @@ -299,6 +314,7 @@ private fun HomeScreen( CrossSellBottomSheet( state = crossSellBottomSheetState, markCrossSellsNotificationAsSeen = markCrossSellsNotificationAsSeen, + onCrossSellsShown = onCrossSellsShown, onCrossSellClick = openCrossSellUrl, imageLoader = imageLoader, ) @@ -418,6 +434,8 @@ private fun HomeScreen( imageLoader = imageLoader, navigateToTravelCertificate = navigateToTravelCertificate, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, + onHomeCrossSellImpression = onHomeCrossSellImpression, + onHomeAddonImpression = onHomeAddonImpression, ) } } @@ -578,6 +596,8 @@ private fun HomeScreenSuccess( imageLoader: ImageLoader, navigateToTravelCertificate: () -> Unit, navigateToAddonPurchaseFlow: (List) -> Unit, + onHomeCrossSellImpression: (CrossSell) -> Unit = {}, + onHomeAddonImpression: (AddonBannerInfo) -> Unit = {}, modifier: Modifier = Modifier, ) { val consumedWindowInsets = remember { MutableWindowInsets() } @@ -875,6 +895,7 @@ private fun HomeScreenSuccess( OffersSection( recommendedCrossSell = recommended, onCrossSellClick = openCrossSellUrl, + onImpression = { onHomeCrossSellImpression(recommended.crossSell) }, imageLoader = imageLoader, horizontalInsets = horizontalInsets, ) @@ -883,12 +904,14 @@ private fun HomeScreenSuccess( HomeSection.DiscoverInsurances -> DiscoverInsurancesSection( crossSells = uiState.crossSellsPartition.discoverCrossSells, onCrossSellClick = openCrossSellUrl, + onCrossSellImpression = onHomeCrossSellImpression, imageLoader = imageLoader, ) HomeSection.Addons -> AddonsSection( addonBannerInfos = uiState.addonBannerInfos, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, + onAddonImpression = onHomeAddonImpression, horizontalInsets = horizontalInsets, imageLoader = imageLoader, ) @@ -1100,12 +1123,19 @@ private fun OffersSection( onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, horizontalInsets: PaddingValues, + onImpression: () -> Unit = {}, ) { val crossSell = recommendedCrossSell.crossSell Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onImpression() + } .padding(horizontal = 16.dp) .padding(horizontalInsets), ) { @@ -1294,6 +1324,7 @@ private fun AddonsSection( navigateToAddonPurchaseFlow: (List) -> Unit, horizontalInsets: PaddingValues, imageLoader: ImageLoader, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -1317,7 +1348,14 @@ private fun AddonsSection( buttonText = stringResource(Res.string.ADDON_FLOW_LEARN_MORE_BUTTON), onButtonClick = { navigateToAddonPurchaseFlow(addon.eligibleInsurancesIds) }, imageLoader = imageLoader, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onAddonImpression(addon) + }, buttonSize = ButtonSize.Small, buttonShape = HedvigTheme.shapes.cornerFull, ) @@ -1330,6 +1368,7 @@ private fun DiscoverInsurancesSection( crossSells: List, onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, + onCrossSellImpression: ((CrossSell) -> Unit)? = null, ) { CrossSellsSection( title = stringResource(Res.string.CROSS_SELL_SUBTITLE), @@ -1340,6 +1379,7 @@ private fun DiscoverInsurancesSection( imageLoader = imageLoader, buttonSize = ButtonSize.Small, buttonShape = HedvigTheme.shapes.cornerFull, + onCrossSellImpression = onCrossSellImpression, ) } @@ -1384,6 +1424,7 @@ private fun WelcomeMessage(homeText: HomeText, modifier: Modifier = Modifier) { private fun CrossSellBottomSheet( state: HedvigBottomSheetState, markCrossSellsNotificationAsSeen: () -> Unit, + onCrossSellsShown: (CrossSellSheetData) -> Unit, onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, ) { @@ -1391,6 +1432,7 @@ private fun CrossSellBottomSheet( snapshotFlow { state.isVisible }.distinctUntilChanged().collect { isVisible -> if (isVisible) { markCrossSellsNotificationAsSeen() + state.data?.let(onCrossSellsShown) } } } @@ -1518,6 +1560,7 @@ private fun PreviewHomeScreen( deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, @@ -1554,6 +1597,7 @@ private fun PreviewHomeScreenWithError() { deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, @@ -1612,6 +1656,7 @@ private fun PreviewHomeScreenAllHomeTextTypes( deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt index d716be1460..477816b1ba 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt @@ -12,8 +12,12 @@ import androidx.compose.runtime.snapshots.Snapshot import arrow.core.Either import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedCrossSell +import com.hedvig.android.crosssells.crossSellSheetShown import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.data.contract.CrossSell @@ -42,6 +46,7 @@ internal class HomePresenter( private val applicationScope: ApplicationScope, private val isProduction: Boolean, private val deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: HomeUiState): HomeUiState { @@ -50,6 +55,7 @@ internal class HomePresenter( var successData: SuccessData? by remember { mutableStateOf(SuccessData.fromLastState(lastState)) } var loadIteration by remember { mutableIntStateOf(0) } var crossSellToolTipShownEpochDay by remember { mutableStateOf(null) } + val trackedHomeImpressions = remember { mutableSetOf() } val alreadySeenImportantMessages: List by seenImportantMessagesStorage.seenMessages.collectAsState() @@ -69,6 +75,22 @@ internal class HomePresenter( } } + is HomeEvent.CrossSellsShown -> { + crossSellImpressionTracker.crossSellSheetShown(homeEvent.crossSells, CrossSellUserFlow.InsuranceCard) + } + + is HomeEvent.HomeCrossSellImpression -> { + val impressionKey = "${homeEvent.crossSellType.analyticsValue}:${homeEvent.offerId}" + if (trackedHomeImpressions.add(impressionKey)) { + crossSellImpressionTracker.crossSellShown( + userFlow = CrossSellUserFlow.HomeScreen, + crossSellType = homeEvent.crossSellType, + offerId = homeEvent.offerId, + flowSource = null, + ) + } + } + is HomeEvent.CrossSellToolTipShown -> { crossSellToolTipShownEpochDay = homeEvent.epochDay } @@ -169,6 +191,10 @@ internal sealed interface HomeEvent { data object MarkCardCrossSellsAsSeen : HomeEvent + data class CrossSellsShown(val crossSells: CrossSellSheetData) : HomeEvent + + data class HomeCrossSellImpression(val offerId: String, val crossSellType: CrossSellType) : HomeEvent + data class CrossSellToolTipShown(val epochDay: Long) : HomeEvent data class DeleteDraftClaim(val draftId: String) : HomeEvent diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt index 92584d1d60..55e1d24b49 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt @@ -4,6 +4,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.feature.home.home.data.GetHomeDataUseCase import com.hedvig.android.feature.home.home.data.SeenImportantMessagesStorage @@ -20,6 +21,7 @@ internal class HomeViewModel( applicationScope: ApplicationScope, hedvigBuildConstants: HedvigBuildConstants, deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( HomeUiState.Loading, HomePresenter( @@ -29,5 +31,6 @@ internal class HomeViewModel( applicationScope, hedvigBuildConstants.isProduction, deleteClaimIntentDraftUseCase, + crossSellImpressionTracker, ), ) diff --git a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt index 4c12dc5366..d43d6a795e 100644 --- a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt +++ b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt @@ -16,6 +16,7 @@ import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell @@ -83,6 +84,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -111,6 +113,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -137,6 +140,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -214,6 +218,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val addonOnlyCrossSells = CrossSellSheetData(null, listOf(), testAddon) @@ -257,6 +262,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -311,6 +317,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -336,6 +343,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -375,6 +383,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -425,6 +434,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val firstVet = FirstVetSection( buttonTitle = "ButtonTitle", @@ -482,6 +492,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val crossSell = CrossSell( id = "id", @@ -546,6 +557,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -595,6 +607,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -644,6 +657,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val otherCrossSell = CrossSell( id = "other", @@ -683,6 +697,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, deleteClaimIntentDraftUseCase, + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -711,6 +726,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -738,6 +754,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, deleteClaimIntentDraftUseCase, + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -792,6 +809,8 @@ private class FakeCrossSellHomeNotificationService : CrossSellHomeNotificationSe } } +private val noopCrossSellImpressionTracker = CrossSellImpressionTracker { _, _, _, _ -> } + private class TestDeleteClaimIntentDraftUseCase : DeleteClaimIntentDraftUseCase { val deletedIdsTurbine = Turbine() var result: Either = Unit.right() From a7d70e31beef453612de43a4036674c46ec3ce92 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:31:05 +0200 Subject: [PATCH 4/6] analytics: track cross_sell_shown on the insurances tab Report an impression per offer as it scrolls into view on the insurances tab (user_flow=insurance_screen), covering both the cross-sell list and the addon banners (identified by flow type, since addon banners carry no per-offer id). Impressions are deduped per screen visit in the presenter. --- .../insurance/InsuranceDestination.kt | 24 +++++++++++++++++++ .../presentation/InsurancePresenter.kt | 23 +++++++++++++++++- .../presentation/InsuranceViewModel.kt | 3 +++ .../presentation/InsurancePresenterTest.kt | 12 ++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt index c23518b8ae..596b3d5102 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading @@ -53,7 +54,10 @@ import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterPr import com.hedvig.android.compose.ui.preview.PreviewContentWithProvidedParametersAnimatedOnClick import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_DURATION_MS +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE import com.hedvig.android.crosssells.CrossSellItemPlaceholder +import com.hedvig.android.crosssells.CrossSellType import com.hedvig.android.crosssells.CrossSellsSection import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.FlowType @@ -129,6 +133,13 @@ internal fun InsuranceDestination( onNavigateToMovingFlow = onNavigateToMovingFlow, imageLoader = imageLoader, onNavigateToAddonPurchaseFlow = onNavigateToAddonPurchaseFlow, + onCrossSellImpression = { crossSell -> + viewModel.emit(InsuranceScreenEvent.CrossSellShown(crossSell.id, CrossSellType.NewPromise)) + }, + onAddonImpression = { bannerInfo -> + // Addon banners carry no per-offer id, so the flow type identifies the addon offer. + viewModel.emit(InsuranceScreenEvent.CrossSellShown(bannerInfo.flowType.name, CrossSellType.Addon)) + }, ) } @@ -142,6 +153,8 @@ private fun InsuranceScreen( onNavigateToMovingFlow: () -> Unit, imageLoader: ImageLoader, onNavigateToAddonPurchaseFlow: (List) -> Unit, + onCrossSellImpression: (CrossSell) -> Unit = {}, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, ) { val isRetrying = uiState.isRetrying val systemBarInsetTopDp = with(LocalDensity.current) { @@ -188,6 +201,8 @@ private fun InsuranceScreen( modifier = Modifier.fillMaxSize(), pullRefreshState = pullRefreshState, onNavigateToAddonPurchaseFlow = onNavigateToAddonPurchaseFlow, + onCrossSellImpression = onCrossSellImpression, + onAddonImpression = onAddonImpression, ) } } @@ -212,6 +227,8 @@ private fun InsuranceScreenContent( navigateToCancelledInsurances: () -> Unit, onNavigateToMovingFlow: () -> Unit, onNavigateToAddonPurchaseFlow: (List) -> Unit, + onCrossSellImpression: (CrossSell) -> Unit = {}, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, modifier: Modifier = Modifier, ) { Column( @@ -265,6 +282,7 @@ private fun InsuranceScreenContent( onSheetDismissed = {}, imageLoader = imageLoader, hasCrossSellDiscounts = uiState.hasCrossSellDiscounts, + onCrossSellImpression = onCrossSellImpression, ) } if (uiState.addonBannerInfoList.isNotEmpty()) { @@ -287,6 +305,12 @@ private fun InsuranceScreenContent( }, modifier = Modifier .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onAddonImpression(bannerInfo) + } .padding(horizontal = 16.dp), ) if (index != uiState.addonBannerInfoList.lastIndex) { diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt index 121664970f..8195585ab1 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt @@ -11,6 +11,9 @@ import androidx.compose.runtime.snapshots.Snapshot import arrow.core.Either import arrow.core.raise.either import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellImpressionTracker +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.addons.data.GetAddonBannerInfoUseCase @@ -30,6 +33,8 @@ import kotlinx.coroutines.flow.flow internal sealed interface InsuranceScreenEvent { data object RetryLoading : InsuranceScreenEvent + + data class CrossSellShown(val offerId: String, val crossSellType: CrossSellType) : InsuranceScreenEvent } internal data class InsuranceUiState( @@ -63,6 +68,7 @@ internal class InsurancePresenter( private val getInsuranceContractsUseCase: GetInsuranceContractsUseCase, private val getCrossSellsUseCase: GetCrossSellsUseCase, private val getAddonBannerInfoUseCase: GetAddonBannerInfoUseCase, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: InsuranceUiState): InsuranceUiState { @@ -75,10 +81,25 @@ internal class InsurancePresenter( var isRetrying by remember { mutableStateOf(false) } var didFailToLoad by remember { mutableStateOf(false) } var loadIteration by remember { mutableIntStateOf(0) } + val trackedImpressions = remember { mutableSetOf() } CollectEvents { event -> when (event) { - InsuranceScreenEvent.RetryLoading -> loadIteration++ + InsuranceScreenEvent.RetryLoading -> { + loadIteration++ + } + + is InsuranceScreenEvent.CrossSellShown -> { + val impressionKey = "${event.crossSellType.analyticsValue}:${event.offerId}" + if (trackedImpressions.add(impressionKey)) { + crossSellImpressionTracker.crossSellShown( + userFlow = CrossSellUserFlow.InsuranceScreen, + crossSellType = event.crossSellType, + offerId = event.offerId, + flowSource = null, + ) + } + } } } diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt index 095b1f8aec..35c4dc21c0 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt @@ -2,6 +2,7 @@ package com.hedvig.android.feature.insurances.insurance.presentation import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.addons.data.GetAddonBannerInfoUseCase import com.hedvig.android.feature.insurances.data.GetCrossSellsUseCase import com.hedvig.android.feature.insurances.data.GetInsuranceContractsUseCase @@ -14,11 +15,13 @@ internal class InsuranceViewModel( getInsuranceContractsUseCase: GetInsuranceContractsUseCase, getCrossSellsUseCase: GetCrossSellsUseCase, getAddonBannerInfoUseCase: GetAddonBannerInfoUseCase, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( initialState = InsuranceUiState.initialState, presenter = InsurancePresenter( getInsuranceContractsUseCase = getInsuranceContractsUseCase, getCrossSellsUseCase = getCrossSellsUseCase, getAddonBannerInfoUseCase = getAddonBannerInfoUseCase, + crossSellImpressionTracker = crossSellImpressionTracker, ), ) diff --git a/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt b/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt index 1ab23b44eb..a41a53ed75 100644 --- a/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt +++ b/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt @@ -18,6 +18,7 @@ import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.addons.data.FlowType @@ -46,6 +47,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +private val noopCrossSellImpressionTracker = CrossSellImpressionTracker { _, _, _, _ -> } + @RunWith(TestParameterInjector::class) internal class InsurancePresenterTest { @get:Rule @@ -273,6 +276,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { awaitItem().also { uiState -> @@ -307,6 +311,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -332,6 +337,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -357,6 +363,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -401,6 +408,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val allContracts = validContracts + terminatedContracts presenter.test(InsuranceUiState.initialState) { @@ -433,6 +441,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val contracts = validContracts.map { it.copy(supportsAddressChange = supportsAddressChange) } presenter.test(InsuranceUiState.initialState) { @@ -454,6 +463,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -473,6 +483,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -492,6 +503,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val initialState = InsuranceUiState( contracts = listOf(), From 10b12a06675e73caac203f1e61886eab6a37cd0e Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:29:45 +0200 Subject: [PATCH 5/6] analytics: emit offer_type instead of cross_sell_type Match the field name used on web (and in the tracking registry) so the column lines up across platforms. Only the emitted key changes; the internal enum stays CrossSellType. --- .../com/hedvig/android/crosssells/CrossSellImpressionTracker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt index d93c80ed45..d81222c05c 100644 --- a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt +++ b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt @@ -83,7 +83,7 @@ internal class CrossSellImpressionTrackerImpl( name = CROSS_SELL_SHOWN_EVENT, parameters = buildMap { put("user_flow", userFlow.analyticsValue) - put("cross_sell_type", crossSellType.analyticsValue) + put("offer_type", crossSellType.analyticsValue) put("offer_id", offerId) if (flowSource != null) { put("flow_source", flowSource.analyticsValue) From 76eab6aabfa25fb47d63523aa5ea613ee69885a0 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:47:33 +0200 Subject: [PATCH 6/6] analytics: lowercase addon flow-type used as offer_id For list add-on banners (insurances tab, inline home) the offer_id is the add-on flow type; lowercase it so it reads as snake_case like the other enum values in the registry (app_travel_plus_sell_only, app_car_plus). --- .../hedvig/android/feature/home/home/ui/HomeDestination.kt | 5 +++-- .../feature/insurances/insurance/InsuranceDestination.kt | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index 88e3918423..ee3949fcbd 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -257,8 +257,9 @@ internal fun HomeDestination( viewModel.emit(HomeEvent.HomeCrossSellImpression(crossSell.id, CrossSellType.NewPromise)) }, onHomeAddonImpression = { bannerInfo -> - // Addon banners carry no per-offer id, so the flow type identifies the addon offer. - viewModel.emit(HomeEvent.HomeCrossSellImpression(bannerInfo.flowType.name, CrossSellType.Addon)) + // Addon banners carry no per-offer id, so the flow type (lowercased to match enum-value casing) + // identifies the addon offer. + viewModel.emit(HomeEvent.HomeCrossSellImpression(bannerInfo.flowType.name.lowercase(), CrossSellType.Addon)) }, navigateToContactInfo = navigateToContactInfo, navigateToChipIdScreen = navigateToChipId, diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt index 596b3d5102..e64ec19fb8 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt @@ -137,8 +137,9 @@ internal fun InsuranceDestination( viewModel.emit(InsuranceScreenEvent.CrossSellShown(crossSell.id, CrossSellType.NewPromise)) }, onAddonImpression = { bannerInfo -> - // Addon banners carry no per-offer id, so the flow type identifies the addon offer. - viewModel.emit(InsuranceScreenEvent.CrossSellShown(bannerInfo.flowType.name, CrossSellType.Addon)) + // Addon banners carry no per-offer id, so the flow type (lowercased to match enum-value casing) + // identifies the addon offer. + viewModel.emit(InsuranceScreenEvent.CrossSellShown(bannerInfo.flowType.name.lowercase(), CrossSellType.Addon)) }, ) }