diff --git a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls index 1ff9b132a4..581446e949 100644 --- a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls +++ b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls @@ -1946,6 +1946,26 @@ type CrossSell { """ options: [CrossSell!]! } +type CrossSellCarouselItem { + title: String! + subtitle: String + """ + The slide image, or the poster when the slide is a video + """ + image: StoryblokImageAsset! + """ + HEVC mp4 for iOS, set when the slide is a video; play it over the image + """ + videoUrlMp4: String + """ + VP9 webm for Android, same clip as videoUrlMp4 + """ + videoUrlWebm: String + """ + Replay the video while the slide is shown; otherwise play once and hold the last frame + """ + loop: Boolean! +} """ A/B testing experiment (name and variant must be SCREAMING_SNAKE_CASE) """ @@ -1953,6 +1973,10 @@ input CrossSellExperimentInput { name: String! variant: String! } +type CrossSellHeroIntro { + title: String! + description: String! +} input CrossSellInput { userFlow: UserFlow! """ @@ -1960,7 +1984,8 @@ input CrossSellInput { """ flowSource: FlowSource """ - A/B test experiments for attribution tracking + A/B test experiments for attribution tracking. ACCIDENT_DISCOUNT with variant NO_DISCOUNT withholds the accident + cross-sell's discount. """ experiments: [CrossSellExperimentInput!]! """ @@ -1972,6 +1997,21 @@ input CrossSellInput { """ claimId: UUID } +type CrossSellProductInfo { + heroImage: StoryblokImageAsset! + """ + The intro text under the hero on the product's web page; null when the page has none + """ + heroIntro: CrossSellHeroIntro + """ + Selling points from the product's web page's image-text sections, in page order + """ + usps: [CrossSellUsp!]! + """ + Story-style slides from the product's web page, in page order + """ + carousel: [CrossSellCarouselItem!]! +} enum CrossSellSource { HOME CLOSED_CLAIM @@ -1980,6 +2020,10 @@ enum CrossSellSource { EDIT_COINSURED MOVING_FLOW } +type CrossSellUsp { + title: String! + description: String! +} type CrossSellV2 { """ Prioritized recommendation (null for INSURANCES) @@ -2971,6 +3015,10 @@ type Member { """ crossSellV2(input: CrossSellInput!): CrossSellV2! """ + Product details for a tapped cross-sell, in the Hedvig-Language of the request. Errors for an unknown id and for the generic HOME, PET and ALL cross-sells. + """ + crossSellProductInfo(crossSellId: ID!): CrossSellProductInfo! + """ Shop sessions started by this member that have not yet been signed and are not expired. Ordered by most recent activity first. """ diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index d081bd3791..6be8934c46 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -225,6 +225,7 @@ dependencies { implementation(projects.featurePayments) implementation(projects.featurePaymentsNavigation) implementation(projects.featureProfile) + implementation(projects.featureProductInApp) implementation(projects.featureTerminateInsurance) implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.featureTravelCertificate) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index 39001d382c..265ec6f1b0 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -10,6 +10,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.contract.CrossSell import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseKey import com.hedvig.android.feature.addon.purchase.navigation.addonPurchaseEntries @@ -60,6 +61,7 @@ import com.hedvig.android.feature.payments.navigation.ManualChargeKey import com.hedvig.android.feature.payments.navigation.paymentsEntries import com.hedvig.android.feature.payoutaccount.navigation.PayoutAccountKey import com.hedvig.android.feature.payoutaccount.navigation.payoutAccountEntries +import com.hedvig.android.feature.product.inapp.navigation.productInAppEntries import com.hedvig.android.feature.profile.navigation.ContactInfoKey import com.hedvig.android.feature.profile.navigation.UsageDataKey import com.hedvig.android.feature.profile.tab.profileEntries @@ -103,6 +105,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( androidAppHost: AndroidAppHost, openUrl: (String) -> Unit, openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, imageLoader: ImageLoader, languageService: LanguageService, hedvigBuildConstants: HedvigBuildConstants, @@ -137,7 +140,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( externalNavigator = externalNavigator, imageLoader = imageLoader, openUrl = openUrl, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, onNavigateToImageViewer = onNavigateToImageViewer, navigateToNewConversation = navigateToNewConversation, navigateToConversation = navigateToConversation, @@ -153,7 +156,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( windowSizeClass = windowSizeClass, imageLoader = imageLoader, openUrl = openUrl, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, externalNavigator = externalNavigator, navigateToNewConversation = navigateToNewConversation, navigateToMovingFlowFromInsurance = { @@ -202,6 +205,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( imageLoader = imageLoader, openUrl = openUrl, openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, externalNavigator = externalNavigator, navigateToNewConversation = navigateToNewConversation, navigateToInbox = navigateToInbox, @@ -247,7 +251,7 @@ private fun EntryProviderScope.addHomeEntries( externalNavigator: ExternalNavigator, imageLoader: ImageLoader, openUrl: (String) -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, onNavigateToImageViewer: (imageUrl: String, cacheKey: String) -> Unit, navigateToNewConversation: () -> Unit, navigateToConversation: (String) -> Unit, @@ -305,7 +309,7 @@ private fun EntryProviderScope.addHomeEntries( navigateToUsageData = { backstack.add(UsageDataKey) }, openAppSettings = externalNavigator::openAppSettings, openUrl = openUrl, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, imageLoader = imageLoader, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, ) @@ -375,7 +379,7 @@ private fun EntryProviderScope.addInsuranceEntries( windowSizeClass: WindowSizeClass, imageLoader: ImageLoader, openUrl: (String) -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, externalNavigator: ExternalNavigator, navigateToNewConversation: () -> Unit, navigateToMovingFlowFromInsurance: () -> Unit, @@ -418,7 +422,7 @@ private fun EntryProviderScope.addInsuranceEntries( }, backstack = backstack, openUrl = openUrl, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, onNavigateToNewConversation = navigateToNewConversation, startMovingFlow = navigateToMovingFlowFromInsurance, startTerminationFlow = { data: CancelInsuranceData -> @@ -585,6 +589,7 @@ private fun EntryProviderScope.addSharedFlowEntries( imageLoader: ImageLoader, openUrl: (String) -> Unit, openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, externalNavigator: ExternalNavigator, navigateToNewConversation: () -> Unit, navigateToInbox: () -> Unit, @@ -623,11 +628,12 @@ private fun EntryProviderScope.addSharedFlowEntries( imageLoader = imageLoader, ) imageViewerEntries(backstack, imageLoader) + productInAppEntries(backstack = backstack, imageLoader = imageLoader, openCrossSellUrl = openCrossSellUrl) removeAddonsEntries(backstack = backstack) onboardingEntries( backstack = backstack, imageLoader = imageLoader, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, openPrivacyPolicy = openPrivacyPolicy, navigateToChipId = { contractId -> backstack.add(ChipIdKey(contractId)) }, ) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index f7c3e4fd6f..3a0fe954cf 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -68,6 +68,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.demomode.DemoManager import com.hedvig.android.core.tracking.EventTrackingClient import com.hedvig.android.data.addons.data.AddonBannerSource +import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.design.system.hedvig.DemoModeLabel import com.hedvig.android.design.system.hedvig.Surface @@ -79,7 +80,9 @@ import com.hedvig.android.feature.home.home.navigation.HomeKey import com.hedvig.android.feature.onboarding.data.ResetOnboardingSeenUseCase import com.hedvig.android.feature.onboarding.gate.OnboardingGate import com.hedvig.android.feature.onboarding.navigation.OnboardingKey +import com.hedvig.android.feature.product.inapp.navigation.navigateToCrossSellProduct import com.hedvig.android.featureflags.FeatureManager +import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.activity.ExternalNavigator @@ -176,9 +179,21 @@ internal fun HedvigApp( scope = scope, ) } + val showProductPages by remember(featureManager) { + featureManager.isFeatureEnabled(Feature.SHOW_PRODUCT_PAGES) + }.collectAsStateWithLifecycle(initialValue = false) + val openCrossSell: (CrossSell) -> Boolean = { crossSell -> + if (showProductPages) { + backstackController.navigateToCrossSellProduct(crossSell) + true + } else { + authorizationCodeUriHandler.tryOpenUri(crossSell.storeUrl) + } + } CrossSellSheet( isInScreenEligibleForCrossSells = hedvigAppState.isInScreenEligibleForCrossSells, - onCrossSellClick = authorizationCodeUriHandler::tryOpenUri, + onCrossSellClick = openCrossSell, + onRecommendedAddonClick = { deepLink -> authorizationCodeUriHandler.tryOpenUri(deepLink) }, onAddonClick = { eligibleInsuranceIds -> backstackController.add( AddonPurchaseKey( @@ -244,6 +259,7 @@ internal fun HedvigApp( androidAppHost = androidAppHost, openUrl = authorizationCodeUriHandler::openUri, openCrossSellUrl = authorizationCodeUriHandler::tryOpenUri, + openCrossSell = openCrossSell, imageLoader = imageLoader, languageService = languageService, hedvigBuildConstants = hedvigBuildConstants, diff --git a/app/data/data-contract/src/commonMain/kotlin/com/hedvig/android/data/contract/CrossSell.kt b/app/data/data-contract/src/commonMain/kotlin/com/hedvig/android/data/contract/CrossSell.kt index f3d3b38b20..baef3b9d12 100644 --- a/app/data/data-contract/src/commonMain/kotlin/com/hedvig/android/data/contract/CrossSell.kt +++ b/app/data/data-contract/src/commonMain/kotlin/com/hedvig/android/data/contract/CrossSell.kt @@ -5,6 +5,8 @@ package com.hedvig.android.data.contract * Pick the one matching the box it is drawn into: the same instance is shown both as a 48.dp list row and as * a 140.dp hero, and reusing one URL across both sizes makes Coil serve the larger cached bitmap to the * smaller box, which visibly softens its edges. + * + * A generic cross-sell, such as pet, has the products to choose between in [options]; a single product has none. */ data class CrossSell( val id: String, @@ -14,6 +16,7 @@ data class CrossSell( val pillowImageSmall: ImageAsset, val pillowImageLarge: ImageAsset, val buttonText: String, + val options: List = emptyList(), ) data class ImageAsset( diff --git a/app/feature/feature-cross-sell-sheet/src/commonMain/graphql/QueryBottomSheetCrossSells.graphql b/app/feature/feature-cross-sell-sheet/src/commonMain/graphql/QueryBottomSheetCrossSells.graphql index 8c0a4e9e17..cb1ba55ada 100644 --- a/app/feature/feature-cross-sell-sheet/src/commonMain/graphql/QueryBottomSheetCrossSells.graphql +++ b/app/feature/feature-cross-sell-sheet/src/commonMain/graphql/QueryBottomSheetCrossSells.graphql @@ -58,4 +58,21 @@ fragment CrossSellFragment on CrossSell { id alt } + options { + id + title + description + storeUrl + buttonTitle + pillowImageSmall { + src + id + alt + } + pillowImageLarge { + src + id + alt + } + } } diff --git a/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheet.kt b/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheet.kt index a3e1acc982..ddc121b9fe 100644 --- a/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheet.kt +++ b/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheet.kt @@ -24,7 +24,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun CrossSellSheet( isInScreenEligibleForCrossSells: Boolean, - onCrossSellClick: (String) -> Boolean, + onCrossSellClick: (CrossSell) -> Boolean, + onRecommendedAddonClick: (deepLink: String) -> Unit, onAddonClick: (eligibleInsuranceIds: List) -> Unit, imageLoader: ImageLoader, ) { @@ -76,11 +77,11 @@ fun CrossSellSheet( viewModel.emit(CrossSellSheetEvent.CrossSellShown(offerId, offerUrl)) }, onCrossSellClick = dropUnlessResumed { crossSell: CrossSell -> - if (onCrossSellClick(crossSell.storeUrl)) { + if (onCrossSellClick(crossSell)) { viewModel.emit(CrossSellSheetEvent.CrossSellClicked(crossSell.id, crossSell.storeUrl)) } }, - onRecommendedAddonClick = { deepLink -> onCrossSellClick(deepLink) }, + onRecommendedAddonClick = onRecommendedAddonClick, onAddonClick = onAddonClick, imageLoader = imageLoader, ) diff --git a/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt b/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt index b343586e3b..097d79ddce 100644 --- a/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt +++ b/app/feature/feature-cross-sell-sheet/src/commonMain/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt @@ -253,6 +253,25 @@ internal fun CrossSellFragment.toCrossSell(): CrossSell { description = pillowImageLarge.alt, ), buttonText = this.buttonTitle, + options = options.map { option -> + CrossSell( + id = option.id, + title = option.title, + subtitle = option.description, + storeUrl = option.storeUrl, + pillowImageSmall = ImageAsset( + id = option.pillowImageSmall.id, + src = option.pillowImageSmall.src, + description = option.pillowImageSmall.alt, + ), + pillowImageLarge = ImageAsset( + id = option.pillowImageLarge.id, + src = option.pillowImageLarge.src, + description = option.pillowImageLarge.alt, + ), + buttonText = option.buttonTitle, + ) + }, ) } } diff --git a/app/feature/feature-home/src/main/graphql/QueryHome.graphql b/app/feature/feature-home/src/main/graphql/QueryHome.graphql index 33a00c0c62..5711928491 100644 --- a/app/feature/feature-home/src/main/graphql/QueryHome.graphql +++ b/app/feature/feature-home/src/main/graphql/QueryHome.graphql @@ -128,4 +128,21 @@ fragment HomeCrossSellFragment on CrossSell { id alt } + options { + id + title + description + storeUrl + buttonTitle + pillowImageSmall { + src + id + alt + } + pillowImageLarge { + src + id + alt + } + } } diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt index 4e7a49b865..bb1e9f6a73 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt @@ -384,6 +384,25 @@ internal class GetHomeDataUseCaseImpl( description = pillowImageLarge.alt, ), buttonText = this.buttonTitle, + options = options.map { option -> + CrossSell( + id = option.id, + title = option.title, + subtitle = option.description, + storeUrl = option.storeUrl, + pillowImageSmall = ImageAsset( + id = option.pillowImageSmall.id, + src = option.pillowImageSmall.src, + description = option.pillowImageSmall.alt, + ), + pillowImageLarge = ImageAsset( + id = option.pillowImageLarge.id, + src = option.pillowImageLarge.src, + description = option.pillowImageLarge.alt, + ), + buttonText = option.buttonTitle, + ) + }, ) } } diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt index 432fcfa8b5..d81364f749 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt @@ -6,6 +6,7 @@ import coil3.ImageLoader import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.feature.home.home.ui.FirstVetDestination import com.hedvig.android.feature.home.home.ui.HomeDestination import com.hedvig.android.feature.home.home.ui.HomeViewModel @@ -36,7 +37,7 @@ fun EntryProviderScope.homeEntries( navigateToUsageData: () -> Unit, openAppSettings: () -> Unit, openUrl: (String) -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, imageLoader: ImageLoader, navigateToAddonPurchaseFlow: (List, AddonBannerSource) -> Unit, ) { @@ -65,7 +66,7 @@ fun EntryProviderScope.homeEntries( } }, openUrl = openUrl, - openCrossSellUrl = openCrossSellUrl, + openCrossSell = openCrossSell, openAppSettings = openAppSettings, navigateToFirstVet = dropUnlessResumed { sections -> navigateToFirstVet(sections) }, navigateToContactInfo = dropUnlessResumed { 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 9ee06c7164..ff816b6c4e 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 @@ -282,7 +282,7 @@ internal fun HomeDestination( navigateToHelpCenter: () -> Unit, navigateToQuickLink: (QuickLinkDestination) -> Unit, openUrl: (String) -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, openAppSettings: () -> Unit, navigateToMissingInfo: (String, CoInsuredFlowType) -> Unit, navigateToFirstVet: (List) -> Unit, @@ -312,7 +312,7 @@ internal fun HomeDestination( viewModel.emit(HomeEvent.CrossSellShown(userFlow, offerId, offerUrl)) }, onCrossSellClick = dropUnlessResumed { userFlow: CrossSellUserFlow, crossSell: CrossSell -> - if (openCrossSellUrl(crossSell.storeUrl)) { + if (openCrossSell(crossSell)) { viewModel.emit(HomeEvent.CrossSellClicked(userFlow, crossSell.id, crossSell.storeUrl)) } }, diff --git a/app/feature/feature-insurances/src/main/graphql/QueryCrossSells.graphql b/app/feature/feature-insurances/src/main/graphql/QueryCrossSells.graphql index 4c19b6343c..a6a63cfebc 100644 --- a/app/feature/feature-insurances/src/main/graphql/QueryCrossSells.graphql +++ b/app/feature/feature-insurances/src/main/graphql/QueryCrossSells.graphql @@ -18,6 +18,23 @@ query CrossSells($experiments: [CrossSellExperimentInput!]!) { id alt } + options { + id + title + description + storeUrl + buttonTitle + pillowImageSmall { + src + id + alt + } + pillowImageLarge { + src + id + alt + } + } } } } diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/data/GetCrossSellsUseCaseImpl.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/data/GetCrossSellsUseCaseImpl.kt index a15995321f..5dab80425a 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/data/GetCrossSellsUseCaseImpl.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/data/GetCrossSellsUseCaseImpl.kt @@ -59,6 +59,25 @@ internal class GetCrossSellsUseCaseImpl( src = crossSell.pillowImageLarge.src, description = crossSell.pillowImageLarge.alt, ), + options = crossSell.options.map { option -> + CrossSell( + id = option.id, + title = option.title, + subtitle = option.description, + storeUrl = option.storeUrl, + pillowImageSmall = ImageAsset( + id = option.pillowImageSmall.id, + src = option.pillowImageSmall.src, + description = option.pillowImageSmall.alt, + ), + pillowImageLarge = ImageAsset( + id = option.pillowImageLarge.id, + src = option.pillowImageLarge.src, + description = option.pillowImageLarge.alt, + ), + buttonText = option.buttonTitle, + ) + }, ) } crossSells 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 e80522b44a..f3fc18ec34 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 @@ -107,7 +107,7 @@ import org.jetbrains.compose.resources.stringResource internal fun InsuranceDestination( viewModel: InsuranceViewModel, onInsuranceCardClick: (contractId: String) -> Unit, - onCrossSellClick: (String) -> Boolean, + onCrossSellClick: (CrossSell) -> Boolean, navigateToCancelledInsurances: () -> Unit, imageLoader: ImageLoader, onNavigateToAddonPurchaseFlow: (List) -> Unit, @@ -119,7 +119,7 @@ internal fun InsuranceDestination( onInsuranceCardClick = onInsuranceCardClick, onCrossSellShown = { offerId, offerUrl -> viewModel.emit(InsuranceScreenEvent.CrossSellShown(offerId, offerUrl)) }, onCrossSellClick = dropUnlessResumed { crossSell: CrossSell -> - if (onCrossSellClick(crossSell.storeUrl)) { + if (onCrossSellClick(crossSell)) { viewModel.emit(InsuranceScreenEvent.CrossSellClicked(crossSell.id, crossSell.storeUrl)) } }, diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt index 9fa2e5ca9f..f311e73661 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt @@ -6,6 +6,7 @@ import coil3.ImageLoader import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.contract.ContractId +import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.productvariant.AddonVariant import com.hedvig.android.feature.insurances.data.AvailableAddon import com.hedvig.android.feature.insurances.data.CancelInsuranceData @@ -28,7 +29,7 @@ fun EntryProviderScope.insuranceEntries( backstack: Backstack, onNavigateToNewConversation: () -> Unit, openUrl: (String) -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, startMovingFlow: () -> Unit, onNavigateToStartChangeTier: (contractId: String, isPaymentProtection: Boolean) -> Unit, startTerminationFlow: (cancelInsuranceData: CancelInsuranceData) -> Unit, @@ -50,7 +51,7 @@ fun EntryProviderScope.insuranceEntries( onInsuranceCardClick = dropUnlessResumed { contractId: String -> backstack.add(InsuranceContractDetailKey(contractId)) }, - onCrossSellClick = openCrossSellUrl, + onCrossSellClick = openCrossSell, navigateToCancelledInsurances = dropUnlessResumed { backstack.add(TerminatedInsurancesKey) }, diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql index 5ba752fbd1..eb17a62766 100644 --- a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -54,6 +54,30 @@ query Onboarding($experiments: [CrossSellExperimentInput!]!) { buttonTitle pillowImageSmall { src + id + alt + } + pillowImageLarge { + src + id + alt + } + options { + id + title + description + storeUrl + buttonTitle + pillowImageSmall { + src + id + alt + } + pillowImageLarge { + src + id + alt + } } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index 33989df54b..8a00230c12 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -1,6 +1,7 @@ package com.hedvig.android.feature.onboarding.data import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.data.contract.CrossSell /** * Everything the onboarding flow needs, fetched eagerly in one round trip before the flow is @@ -17,7 +18,7 @@ internal data class OnboardingData( /** The provider of the method behind [payinStatus], or null when none is connected or onboarding can't offer it. */ val connectedPayinProvider: OnboardingPayinProvider? = null, val availablePayinProviders: List, - val crossSells: List, + val crossSells: List, ) { /** Contracts missing co-insured OR co-owner info, i.e. anything the co-insured step should show. */ val contractsMissingInsuredOrOwnerInfo: List = @@ -84,12 +85,3 @@ internal data class OnboardingReferralInformation( val monthlyDiscountPerReferralAmount: Double, val currencyCode: String, ) - -internal data class OnboardingCrossSell( - val id: String, - val title: String, - val description: String, - val storeUrl: String, - val pillowImageUrl: String?, - val buttonTitle: String, -) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index f75647fa35..0430bfd3e6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -10,6 +10,8 @@ import com.hedvig.android.apollo.ErrorMessage import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.experiments.Experiment import com.hedvig.android.experiments.ExperimentProvider import dev.zacsweers.metro.ContributesBinding @@ -113,13 +115,41 @@ internal class OnboardingRepositoryImpl( .filter { it.supportsPayin } .mapNotNull { it.provider.toOnboardingPayinProvider() }, crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> - OnboardingCrossSell( + CrossSell( id = crossSell.id, title = crossSell.title, - description = crossSell.description, + subtitle = crossSell.description, storeUrl = crossSell.storeUrl, - pillowImageUrl = crossSell.pillowImageSmall.src, - buttonTitle = crossSell.buttonTitle, + pillowImageSmall = ImageAsset( + id = crossSell.pillowImageSmall.id, + src = crossSell.pillowImageSmall.src, + description = crossSell.pillowImageSmall.alt, + ), + pillowImageLarge = ImageAsset( + id = crossSell.pillowImageLarge.id, + src = crossSell.pillowImageLarge.src, + description = crossSell.pillowImageLarge.alt, + ), + buttonText = crossSell.buttonTitle, + options = crossSell.options.map { option -> + CrossSell( + id = option.id, + title = option.title, + subtitle = option.description, + storeUrl = option.storeUrl, + pillowImageSmall = ImageAsset( + id = option.pillowImageSmall.id, + src = option.pillowImageSmall.src, + description = option.pillowImageSmall.alt, + ), + pillowImageLarge = ImageAsset( + id = option.pillowImageLarge.id, + src = option.pillowImageLarge.src, + description = option.pillowImageLarge.alt, + ), + buttonText = option.buttonTitle, + ) + }, ) }, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt index 146fb6a684..fd8daab4a2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt @@ -2,6 +2,7 @@ package com.hedvig.android.feature.onboarding.navigation import androidx.navigation3.runtime.EntryProviderScope import coil3.ImageLoader +import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleDestination import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleViewModel import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredDestination @@ -29,7 +30,7 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.onboardingEntries( backstack: Backstack, imageLoader: ImageLoader, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, openPrivacyPolicy: () -> Unit, navigateToChipId: (contractId: String) -> Unit, ) { @@ -82,7 +83,7 @@ fun EntryProviderScope.onboardingEntries( OnboardingStepId.BundleDiscount -> { val viewModel: OnboardingBundleViewModel = metroViewModel() - OnboardingBundleDestination(viewModel, imageLoader, backstack::navigateUp, openCrossSellUrl) + OnboardingBundleDestination(viewModel, imageLoader, backstack::navigateUp, openCrossSell) } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index be301bdef9..a12b2a73c6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -32,6 +32,7 @@ import com.hedvig.android.crosssells.rememberCrossSellShownReporter import com.hedvig.android.crosssells.reportCrossSellShown import com.hedvig.android.crosssells.trackCrossSellClicked import com.hedvig.android.crosssells.trackCrossSellShown +import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigErrorSection @@ -40,7 +41,6 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.rememberPreviewImageLoader -import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -135,7 +135,7 @@ internal sealed interface OnboardingBundleUiState { data class Content( val progress: OnboardingProgress, - val crossSells: List, + val crossSells: List, ) : OnboardingBundleUiState } @@ -156,7 +156,7 @@ internal fun OnboardingBundleDestination( viewModel: OnboardingBundleViewModel, imageLoader: ImageLoader, navigateUp: () -> Unit, - openCrossSellUrl: (String) -> Boolean, + openCrossSell: (CrossSell) -> Boolean, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() OnboardingBundleScreen( @@ -168,8 +168,8 @@ internal fun OnboardingBundleDestination( onRetry = { viewModel.emit(OnboardingBundleEvent.Retry) }, onContinueToApp = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, onCrossSellShown = { offerId, offerUrl -> viewModel.emit(OnboardingBundleEvent.CrossSellShown(offerId, offerUrl)) }, - onCrossSellClick = dropUnlessResumed { crossSell: OnboardingCrossSell -> - if (openCrossSellUrl(crossSell.storeUrl)) { + onCrossSellClick = dropUnlessResumed { crossSell: CrossSell -> + if (openCrossSell(crossSell)) { viewModel.emit(OnboardingBundleEvent.CrossSellClicked(crossSell.id, crossSell.storeUrl)) } }, @@ -186,7 +186,7 @@ private fun OnboardingBundleScreen( onRetry: () -> Unit, onContinueToApp: () -> Unit, onCrossSellShown: (offerId: String, offerUrl: String) -> Unit, - onCrossSellClick: (OnboardingCrossSell) -> Unit, + onCrossSellClick: (CrossSell) -> Unit, ) { val crossSellShownReporter = rememberCrossSellShownReporter(onCrossSellShown) OnboardingStepScaffold( @@ -240,18 +240,16 @@ private fun OnboardingBundleScreen( @Composable private fun OnboardingCrossSellRow( - crossSell: OnboardingCrossSell, + crossSell: CrossSell, shownReporter: CrossSellShownReporter, imageLoader: ImageLoader, - onCrossSellClick: (OnboardingCrossSell) -> Unit, + onCrossSellClick: (CrossSell) -> Unit, ) { PillowRow( title = crossSell.title, - subtitle = crossSell.description, - pillowImage = crossSell.pillowImageUrl?.let { url -> - ImageAsset(id = crossSell.id, src = url, description = null) - }, - buttonText = crossSell.buttonTitle, + subtitle = crossSell.subtitle, + pillowImage = crossSell.pillowImageSmall, + buttonText = crossSell.buttonText, onButtonClick = withOnboardingHaptic { onCrossSellClick(crossSell) }, imageLoader = imageLoader, modifier = Modifier @@ -313,45 +311,50 @@ private class OnboardingBundleUiStateProvider : CollectionPreviewParameterProvid OnboardingBundleUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 1), crossSells = listOf( - OnboardingCrossSell( + CrossSell( id = "home", title = "Home Insurance", - description = "For you, your family and your home", + subtitle = "For you, your family and your home", storeUrl = "https://www.hedvig.com/se/forsakringar/hemforsakring", - pillowImageUrl = null, - buttonTitle = "button", + pillowImageSmall = ImageAsset(id = "home", src = "", description = null), + pillowImageLarge = ImageAsset(id = "home", src = "", description = null), + buttonText = "button", ), - OnboardingCrossSell( + CrossSell( id = "pet", title = "Pet Insurance", - description = "For your dog or cat", + subtitle = "For your dog or cat", storeUrl = "https://www.hedvig.com/se/forsakringar/djurforsakring", - pillowImageUrl = null, - buttonTitle = "button", + pillowImageSmall = ImageAsset(id = "pet", src = "", description = null), + pillowImageLarge = ImageAsset(id = "pet", src = "", description = null), + buttonText = "button", ), - OnboardingCrossSell( + CrossSell( id = "car", title = "Car insurance", - description = "For you and your car", + subtitle = "For you and your car", storeUrl = "https://www.hedvig.com/se/forsakringar/bilforsakring", - pillowImageUrl = null, - buttonTitle = "button", + pillowImageSmall = ImageAsset(id = "car", src = "", description = null), + pillowImageLarge = ImageAsset(id = "car", src = "", description = null), + buttonText = "button", ), - OnboardingCrossSell( + CrossSell( id = "vacation", title = "Vacation Home Insurance", - description = "For your cottage or cabin", + subtitle = "For your cottage or cabin", storeUrl = "https://www.hedvig.com/se/forsakringar/fritidshusforsakring", - pillowImageUrl = null, - buttonTitle = "button", + pillowImageSmall = ImageAsset(id = "vacation", src = "", description = null), + pillowImageLarge = ImageAsset(id = "vacation", src = "", description = null), + buttonText = "button", ), - OnboardingCrossSell( + CrossSell( id = "ppi", title = "Payment Protection Insurance", - description = "For you if you get unemployed", + subtitle = "For you if you get unemployed", storeUrl = "https://www.hedvig.com/se/forsakringar/inkomstforsakring", - pillowImageUrl = null, - buttonTitle = "button", + pillowImageSmall = ImageAsset(id = "ppi", src = "", description = null), + pillowImageLarge = ImageAsset(id = "ppi", src = "", description = null), + buttonText = "button", ), ), ), diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index 1fea6f9053..c7c271322c 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -4,9 +4,10 @@ import app.cash.turbine.Turbine import arrow.core.Either import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.datastore.FakeGetAnalyticsConsentUseCase +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.feature.onboarding.data.OnboardingContract -import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingData import com.hedvig.android.feature.onboarding.data.OnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider @@ -68,14 +69,15 @@ internal fun testOnboardingData( isMissingPayinConnection: Boolean = true, payinStatus: OnboardingPayinStatus = OnboardingPayinStatus.NeedsSetup, connectedPayinProvider: OnboardingPayinProvider? = null, - crossSells: List = listOf( - OnboardingCrossSell( + crossSells: List = listOf( + CrossSell( id = "cs", title = "Pet", - description = "For your pet", + subtitle = "For your pet", storeUrl = "https://x", - pillowImageUrl = "https://x", - buttonTitle = "See price", + pillowImageSmall = ImageAsset(id = "cs", src = "https://x", description = null), + pillowImageLarge = ImageAsset(id = "cs", src = "https://x", description = null), + buttonText = "See price", ), ), ): OnboardingData = OnboardingData( diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index 10da290bfd..cdd45be41b 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -6,6 +6,8 @@ import assertk.assertions.containsExactly import assertk.assertions.doesNotContain import assertk.assertions.hasSize import assertk.assertions.isEqualTo +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -33,14 +35,15 @@ class OnboardingPathTest { referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), isMissingPayinConnection: Boolean = true, payinStatus: OnboardingPayinStatus = OnboardingPayinStatus.NeedsSetup, - crossSells: List = listOf( - OnboardingCrossSell( + crossSells: List = listOf( + CrossSell( id = "cs", title = "Pet", - description = "For your pet", + subtitle = "For your pet", storeUrl = "https://x", - pillowImageUrl = "https://x", - buttonTitle = "See price", + pillowImageSmall = ImageAsset(id = "cs", src = "https://x", description = null), + pillowImageLarge = ImageAsset(id = "cs", src = "https://x", description = null), + buttonText = "See price", ), ), ) = OnboardingData( diff --git a/app/feature/feature-product-in-app/build.gradle.kts b/app/feature/feature-product-in-app/build.gradle.kts new file mode 100644 index 0000000000..a48927e5c2 --- /dev/null +++ b/app/feature/feature-product-in-app/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() + navKeys() + viewModels() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.apollo.normalizedCache) + implementation(libs.apollo.runtime) + implementation(libs.arrow.core) + implementation(libs.coil.coil) + implementation(libs.coil.compose) + implementation(libs.coroutines.core) + implementation(libs.jetbrains.compose.ui.tooling.preview) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.dataContract) + implementation(projects.designSystemHedvig) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + } + androidMain.dependencies { + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + } + jvmTest.dependencies { + implementation(libs.assertK) + implementation(libs.coroutines.test) + implementation(libs.junit) + implementation(libs.turbine) + implementation(projects.loggingTest) + implementation(projects.moleculeTest) + } + } +} diff --git a/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/ProductInAppEntries.kt b/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/ProductInAppEntries.kt new file mode 100644 index 0000000000..348e6353a2 --- /dev/null +++ b/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/ProductInAppEntries.kt @@ -0,0 +1,48 @@ +package com.hedvig.android.feature.product.inapp.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import coil3.ImageLoader +import com.hedvig.android.compose.ui.dropUnlessResumed +import com.hedvig.android.feature.product.inapp.ui.CrossSellOptionsSheetContent +import com.hedvig.android.feature.product.inapp.ui.CrossSellProductDestination +import com.hedvig.android.feature.product.inapp.ui.CrossSellProductHeader +import com.hedvig.android.feature.product.inapp.ui.CrossSellProductViewModel +import com.hedvig.android.feature.product.inapp.ui.CrossSellProductViewModelFactory +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.BottomSheetSceneStrategy +import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel + +fun EntryProviderScope.productInAppEntries( + backstack: Backstack, + imageLoader: ImageLoader, + openCrossSellUrl: (String) -> Boolean, +) { + entry { key -> + val viewModel: CrossSellProductViewModel = + assistedMetroViewModel { + create(key.crossSellId) + } + CrossSellProductDestination( + viewModel = viewModel, + header = CrossSellProductHeader( + title = key.title, + subtitle = key.subtitle, + pillowImageSrc = key.pillowImageSrc, + discountText = key.discountText, + ), + imageLoader = imageLoader, + close = backstack::popBackstack, + openStoreUrl = { openCrossSellUrl(key.storeUrl) }, + ) + } + + entry(metadata = BottomSheetSceneStrategy.bottomSheet()) { key -> + CrossSellOptionsSheetContent( + options = key.options, + imageLoader = imageLoader, + onOptionChosen = dropUnlessResumed { option: CrossSellProductKey -> backstack.navigateToSelectedOption(option) }, + onClose = backstack::popBackstack, + ) + } +} diff --git a/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.android.kt b/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.android.kt new file mode 100644 index 0000000000..98e7f620e8 --- /dev/null +++ b/app/feature/feature-product-in-app/src/androidMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.android.kt @@ -0,0 +1,73 @@ +package com.hedvig.android.feature.product.inapp.ui + +import android.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat + +@Composable +internal actual fun CarouselVideo(url: String, loop: Boolean, modifier: Modifier) { + val context = LocalContext.current + val player = remember(context, url, loop) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + volume = 0f + playWhenReady = true + prepare() + } + } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(player, lifecycleOwner) { + val errorListener = object : Player.Listener { + override fun onPlayerError(error: PlaybackException) { + logcat(LogPriority.WARN, error) { "Cross-sell carousel video failed to play" } + } + } + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + player.play() + } + + Lifecycle.Event.ON_PAUSE -> { + player.pause() + } + + else -> {} + } + } + player.addListener(errorListener) + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + onDispose { + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + player.removeListener(errorListener) + player.release() + } + } + AndroidView( + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + setShutterBackgroundColor(Color.TRANSPARENT) + } + }, + update = { playerView -> playerView.player = player }, + modifier = modifier, + ) +} diff --git a/app/feature/feature-product-in-app/src/commonMain/graphql/CrossSellProductInfoQuery.graphql b/app/feature/feature-product-in-app/src/commonMain/graphql/CrossSellProductInfoQuery.graphql new file mode 100644 index 0000000000..9379f1ef7a --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/graphql/CrossSellProductInfoQuery.graphql @@ -0,0 +1,30 @@ +query CrossSellProductInfo($crossSellId: ID!) { + currentMember { + crossSellProductInfo(crossSellId: $crossSellId) { + heroImage { + id + src + alt + } + heroIntro { + title + description + } + usps { + title + description + } + carousel { + title + subtitle + image { + id + src + alt + } + videoUrlWebm + loop + } + } + } +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/CrossSellProductInfo.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/CrossSellProductInfo.kt new file mode 100644 index 0000000000..5ec40e6989 --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/CrossSellProductInfo.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.product.inapp.data + +import com.hedvig.android.data.contract.ImageAsset + +internal data class CrossSellProductInfo( + val heroImage: ImageAsset, + val heroIntro: HeroIntro?, + val usps: List, + val carousel: List, +) { + data class HeroIntro( + val title: String, + val description: String, + ) + + data class Usp( + val title: String, + val description: String, + ) + + /** + * [image] is the slide itself, or the poster shown under [videoUrl] while a video slide loads. A video + * slide with [loop] false plays once and holds its last frame. + */ + data class CarouselItem( + val title: String, + val subtitle: String?, + val image: ImageAsset, + val videoUrl: String?, + val loop: Boolean, + ) +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/GetCrossSellProductInfoUseCase.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/GetCrossSellProductInfoUseCase.kt new file mode 100644 index 0000000000..858b11bcda --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/data/GetCrossSellProductInfoUseCase.kt @@ -0,0 +1,49 @@ +package com.hedvig.android.feature.product.inapp.data + +import arrow.core.Either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.ApolloOperationError +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.data.contract.ImageAsset +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.CrossSellProductInfoQuery + +internal interface GetCrossSellProductInfoUseCase { + suspend fun invoke(crossSellId: String): Either +} + +@ContributesBinding(AppScope::class) +@Inject +internal class GetCrossSellProductInfoUseCaseImpl( + private val apolloClient: ApolloClient, +) : GetCrossSellProductInfoUseCase { + override suspend fun invoke(crossSellId: String): Either { + return apolloClient + .query(CrossSellProductInfoQuery(crossSellId)) + .safeExecute() + .map { data -> data.currentMember.crossSellProductInfo.toProductInfo() } + .onLeft { error -> + logcat(operationError = error) { "GetCrossSellProductInfoUseCase failed for crossSellId:$crossSellId" } + } + } +} + +private fun CrossSellProductInfoQuery.Data.CurrentMember.CrossSellProductInfo.toProductInfo(): CrossSellProductInfo { + return CrossSellProductInfo( + heroImage = ImageAsset(id = heroImage.id, src = heroImage.src, description = heroImage.alt), + heroIntro = heroIntro?.let { CrossSellProductInfo.HeroIntro(title = it.title, description = it.description) }, + usps = usps.map { CrossSellProductInfo.Usp(title = it.title, description = it.description) }, + carousel = carousel.map { item -> + CrossSellProductInfo.CarouselItem( + title = item.title, + subtitle = item.subtitle, + image = ImageAsset(id = item.image.id, src = item.image.src, description = item.image.alt), + videoUrl = item.videoUrlWebm, + loop = item.loop, + ) + }, + ) +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductKey.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductKey.kt new file mode 100644 index 0000000000..fee374a515 --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductKey.kt @@ -0,0 +1,30 @@ +package com.hedvig.android.feature.product.inapp.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +/** + * The header fields and [storeUrl] come from the tapped cross-sell, so the page can show its header + * before the product info has loaded. [storeUrl] is where the purchase continues. [discountText] is the + * badge on the hero image, or null for no badge. + */ +@androidx.annotation.Keep +@Serializable +data class CrossSellProductKey( + val crossSellId: String, + val title: String, + val subtitle: String, + val pillowImageSrc: String, + val storeUrl: String, + val discountText: String?, +) : HedvigNavKey + +/** + * A bottom sheet for picking one of a generic cross-sell's [options] (for example dog or cat for pet), each of + * which then opens its own product page. + */ +@androidx.annotation.Keep +@Serializable +data class CrossSellOptionsKey( + val options: List, +) : HedvigNavKey diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigation.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigation.kt new file mode 100644 index 0000000000..06826ae23a --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigation.kt @@ -0,0 +1,34 @@ +package com.hedvig.android.feature.product.inapp.navigation + +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo + +/** + * Opens the product page of [crossSell], or, for a generic cross-sell, the sheet to pick one of its options from. + */ +fun Backstack.navigateToCrossSellProduct(crossSell: CrossSell) { + if (crossSell.options.isEmpty()) { + add(crossSell.toCrossSellProductKey()) + } else { + add(CrossSellOptionsKey(options = crossSell.options.map { it.toCrossSellProductKey() })) + } +} + +/** + * Replaces the options sheet with the chosen product page, so leaving that page returns to wherever the + * cross-sell was tapped rather than to the sheet. + */ +internal fun Backstack.navigateToSelectedOption(option: CrossSellProductKey) { + navigateAndPopUpTo(option, inclusive = true) +} + +private fun CrossSell.toCrossSellProductKey(): CrossSellProductKey = CrossSellProductKey( + crossSellId = id, + title = title, + subtitle = subtitle, + pillowImageSrc = pillowImageSmall.src, + storeUrl = storeUrl, + discountText = null, +) diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellOptionsSheetContent.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellOptionsSheetContent.kt new file mode 100644 index 0000000000..742f7c56a8 --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellOptionsSheetContent.kt @@ -0,0 +1,78 @@ +package com.hedvig.android.feature.product.inapp.ui + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.compose.AsyncImage +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback +import com.hedvig.android.feature.product.inapp.navigation.CrossSellProductKey +import hedvig.resources.Res +import hedvig.resources.general_close_button +import hedvig.resources.general_continue_button +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun CrossSellOptionsSheetContent( + options: List, + imageLoader: ImageLoader, + onOptionChosen: (CrossSellProductKey) -> Unit, + onClose: () -> Unit, +) { + var selectedId by rememberSaveable { mutableStateOf(null) } + RadioGroup( + options = options.map { RadioOption(id = RadioOptionId(it.crossSellId), text = it.title) }, + selectedOption = selectedId?.let(::RadioOptionId), + onRadioOptionSelected = { selectedId = it.id }, + optionIcon = { id -> + val placeholder = crossSellPainterFallback(shape = HedvigTheme.shapes.cornerSmall) + AsyncImage( + model = options.firstOrNull { it.crossSellId == id.id }?.pillowImageSrc, + contentDescription = EmptyContentDescription, + placeholder = placeholder, + error = placeholder, + fallback = placeholder, + imageLoader = imageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier.size(32.dp), + ) + }, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.general_continue_button), + enabled = selectedId != null, + onClick = { + options.firstOrNull { it.crossSellId == selectedId }?.let(onOptionChosen) + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(Res.string.general_close_button), + enabled = true, + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductDestination.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductDestination.kt new file mode 100644 index 0000000000..ab044cb741 --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductDestination.kt @@ -0,0 +1,316 @@ +package com.hedvig.android.feature.product.inapp.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.ImageLoader +import coil3.compose.AsyncImage +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.data.contract.ImageAsset +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.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HighlightLabel +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighLightSize +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightColor +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightShade +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.IconButton +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.Close +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback +import com.hedvig.android.design.system.hedvig.rememberPreviewImageLoader +import com.hedvig.android.feature.product.inapp.data.CrossSellProductInfo +import hedvig.resources.Res +import hedvig.resources.general_close_button +import org.jetbrains.compose.resources.stringResource + +internal data class CrossSellProductHeader( + val title: String, + val subtitle: String, + val pillowImageSrc: String, + val discountText: String?, +) + +@Composable +internal fun CrossSellProductDestination( + viewModel: CrossSellProductViewModel, + header: CrossSellProductHeader, + imageLoader: ImageLoader, + close: () -> Unit, + openStoreUrl: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + CrossSellProductScreen( + uiState = uiState, + header = header, + imageLoader = imageLoader, + close = close, + retry = { viewModel.emit(CrossSellProductEvent.Retry) }, + openStoreUrl = openStoreUrl, + ) +} + +@Composable +private fun CrossSellProductScreen( + uiState: CrossSellProductUiState, + header: CrossSellProductHeader, + imageLoader: ImageLoader, + close: () -> Unit, + retry: () -> Unit, + openStoreUrl: () -> Unit, +) { + Surface( + color = HedvigTheme.colorScheme.backgroundPrimary, + modifier = Modifier.fillMaxSize(), + ) { + Column( + Modifier.windowInsetsPadding(WindowInsets.safeDrawing), + ) { + ProductHeader(header = header, imageLoader = imageLoader, close = close) + when (uiState) { + CrossSellProductUiState.Loading -> { + HedvigFullScreenCenterAlignedProgress(Modifier.weight(1f).fillMaxWidth()) + } + + CrossSellProductUiState.Failure -> { + HedvigErrorSection(onButtonClick = retry, modifier = Modifier.weight(1f)) + } + + is CrossSellProductUiState.Content -> { + ProductContent( + productInfo = uiState.productInfo, + discountText = header.discountText, + imageLoader = imageLoader, + modifier = Modifier.weight(1f), + ) + } + } + // TODO: Add "Discover your offer" / "Upptäck ditt erbjudande" to Lokalise + HedvigButton( + text = "Discover your offer", + onClick = openStoreUrl, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } +} + +@Composable +private fun ProductHeader(header: CrossSellProductHeader, imageLoader: ImageLoader, close: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + val placeholder = crossSellPainterFallback(shape = HedvigTheme.shapes.cornerXLarge) + AsyncImage( + model = header.pillowImageSrc, + contentDescription = EmptyContentDescription, + placeholder = placeholder, + error = placeholder, + fallback = placeholder, + imageLoader = imageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp), + ) + Column(Modifier.weight(1f)) { + HedvigText(text = header.title, modifier = Modifier.semantics { heading() }) + HedvigText( + text = header.subtitle, + color = HedvigTheme.colorScheme.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = close, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = HedvigIcons.Close, + contentDescription = stringResource(Res.string.general_close_button), + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Composable +private fun ProductContent( + productInfo: CrossSellProductInfo, + discountText: String?, + imageLoader: ImageLoader, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(8.dp)) + ProductHero(image = productInfo.heroImage, discountText = discountText, imageLoader = imageLoader) + Spacer(Modifier.height(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + productInfo.heroIntro?.let { intro -> + TitleAndDescription(title = intro.title, description = intro.description) + } + for (usp in productInfo.usps) { + TitleAndDescription(title = usp.title, description = usp.description) + } + if (productInfo.carousel.isNotEmpty()) { + CrossSellStoryCarousel( + items = productInfo.carousel, + imageLoader = imageLoader, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(STORY_CARD_ASPECT_RATIO), + ) + } + } + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun ProductHero(image: ImageAsset, discountText: String?, imageLoader: ImageLoader) { + val shape = HedvigTheme.shapes.cornerXLarge + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(HERO_ASPECT_RATIO) + .clip(shape), + ) { + val placeholder = crossSellPainterFallback(shape = shape) + AsyncImage( + model = image.src, + contentDescription = image.description ?: EmptyContentDescription, + placeholder = placeholder, + error = placeholder, + fallback = placeholder, + imageLoader = imageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + Box( + Modifier + .matchParentSize() + .background(HedvigTheme.colorScheme.surfacePrimaryTransparent), + ) + if (discountText != null) { + HighlightLabel( + labelText = discountText, + size = HighLightSize.Small, + color = HighlightColor.Green(HighlightShade.LIGHT), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) + } + } +} + +@Composable +private fun TitleAndDescription(title: String, description: String) { + Column(Modifier.fillMaxWidth()) { + HedvigText(title) + HedvigText(description, color = HedvigTheme.colorScheme.textSecondary) + } +} + +private const val HERO_ASPECT_RATIO = 358f / 423f +private const val STORY_CARD_ASPECT_RATIO = 358f / 498f + +@HedvigPreview +@Composable +private fun PreviewCrossSellProductScreen( + @PreviewParameter(CrossSellProductUiStateProvider::class) uiState: CrossSellProductUiState, +) { + HedvigTheme { + CrossSellProductScreen( + uiState = uiState, + header = CrossSellProductHeader( + title = "Accident Insurance", + subtitle = "Help when you need it the most", + pillowImageSrc = "", + discountText = "-20%", + ), + imageLoader = rememberPreviewImageLoader(), + close = {}, + retry = {}, + openStoreUrl = {}, + ) + } +} + +private class CrossSellProductUiStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + CrossSellProductUiState.Content( + CrossSellProductInfo( + heroImage = ImageAsset(id = "hero", src = "", description = null), + heroIntro = CrossSellProductInfo.HeroIntro( + title = "Compensation", + description = "Up to 1 000 000 kr for permanent injuries and loss of work ability.", + ), + usps = listOf( + CrossSellProductInfo.Usp( + title = "Wide protection", + description = "The insurance is valid worldwide. It also covers many extreme sports.", + ), + CrossSellProductInfo.Usp(title = "No lock-in period", description = "Cancel whenever you want."), + ), + carousel = listOf( + CrossSellProductInfo.CarouselItem( + title = "Help directly in the app", + subtitle = "Our service team is only a click away", + image = ImageAsset(id = "slide", src = "", description = null), + videoUrl = null, + loop = false, + ), + CrossSellProductInfo.CarouselItem( + title = "Second slide", + subtitle = null, + image = ImageAsset(id = "slide2", src = "", description = null), + videoUrl = null, + loop = false, + ), + ), + ), + ), + CrossSellProductUiState.Loading, + CrossSellProductUiState.Failure, + ) +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductViewModel.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductViewModel.kt new file mode 100644 index 0000000000..92cae6d27f --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductViewModel.kt @@ -0,0 +1,73 @@ +package com.hedvig.android.feature.product.inapp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.feature.product.inapp.data.CrossSellProductInfo +import com.hedvig.android.feature.product.inapp.data.GetCrossSellProductInfoUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject + +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class CrossSellProductViewModel( + @Assisted crossSellId: String, + getCrossSellProductInfoUseCase: GetCrossSellProductInfoUseCase, +) : MoleculeViewModel( + initialState = CrossSellProductUiState.Loading, + presenter = CrossSellProductPresenter( + crossSellId = crossSellId, + getCrossSellProductInfoUseCase = getCrossSellProductInfoUseCase, + ), + ) + +internal class CrossSellProductPresenter( + private val crossSellId: String, + private val getCrossSellProductInfoUseCase: GetCrossSellProductInfoUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: CrossSellProductUiState, + ): CrossSellProductUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + CollectEvents { event -> + when (event) { + CrossSellProductEvent.Retry -> loadIteration++ + } + } + + LaunchedEffect(loadIteration) { + if (currentState is CrossSellProductUiState.Content) return@LaunchedEffect + currentState = CrossSellProductUiState.Loading + currentState = getCrossSellProductInfoUseCase.invoke(crossSellId).fold( + ifLeft = { CrossSellProductUiState.Failure }, + ifRight = { CrossSellProductUiState.Content(it) }, + ) + } + + return currentState + } +} + +internal sealed interface CrossSellProductUiState { + data object Loading : CrossSellProductUiState + + data object Failure : CrossSellProductUiState + + data class Content(val productInfo: CrossSellProductInfo) : CrossSellProductUiState +} + +internal sealed interface CrossSellProductEvent { + data object Retry : CrossSellProductEvent +} diff --git a/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellStoryCarousel.kt b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellStoryCarousel.kt new file mode 100644 index 0000000000..88e287d0a9 --- /dev/null +++ b/app/feature/feature-product-in-app/src/commonMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellStoryCarousel.kt @@ -0,0 +1,170 @@ +package com.hedvig.android.feature.product.inapp.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import coil3.ImageLoader +import coil3.compose.AsyncImage +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.product.inapp.data.CrossSellProductInfo + +/** + * Shows one slide at a time and moves to the next after [SLIDE_DURATION_MILLIS], wrapping around after the + * last. Tapping the left half goes back a slide and the right half forward. The timer only runs while the + * screen is resumed, and picks up where it stopped. + */ +@Composable +internal fun CrossSellStoryCarousel( + items: List, + imageLoader: ImageLoader, + modifier: Modifier = Modifier, +) { + var currentIndex by rememberSaveable(items) { mutableIntStateOf(0) } + val goToNext = { currentIndex = (currentIndex + 1) % items.size } + val goToPrevious = { currentIndex = (currentIndex - 1 + items.size) % items.size } + val progress = remember(currentIndex) { Animatable(0f) } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(currentIndex, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + val remainingMillis = ((1f - progress.value) * SLIDE_DURATION_MILLIS).toInt() + progress.animateTo(1f, tween(durationMillis = remainingMillis, easing = LinearEasing)) + goToNext() + } + } + val item = items[currentIndex] + Box( + modifier = modifier + .clip(HedvigTheme.shapes.cornerXXLarge) + .background(HedvigTheme.colorScheme.surfacePrimary) + .pointerInput(items.size) { + detectTapGestures { offset -> + if (offset.x < size.width / 2) goToPrevious() else goToNext() + } + } + .semantics { + if (items.size > 1) { + customActions = listOf( + // TODO: Add "Next" / "Nästa" to Lokalise + CustomAccessibilityAction("Next") { + goToNext() + true + }, + // TODO: Add "Previous" / "Föregående" to Lokalise + CustomAccessibilityAction("Previous") { + goToPrevious() + true + }, + ) + } + }, + ) { + AsyncImage( + model = item.image.src, + contentDescription = item.image.description ?: EmptyContentDescription, + imageLoader = imageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + if (item.videoUrl != null) { + CarouselVideo(url = item.videoUrl, loop = item.loop, modifier = Modifier.matchParentSize()) + } + if (items.size > 1) { + StoryProgress( + count = items.size, + currentIndex = currentIndex, + currentProgress = { progress.value }, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 24.dp, vertical = 16.dp), + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 24.dp), + ) { + HedvigText(text = item.title, textAlign = TextAlign.Center) + if (item.subtitle != null) { + HedvigText( + text = item.subtitle, + textAlign = TextAlign.Center, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } +} + +@Composable +private fun StoryProgress(count: Int, currentIndex: Int, currentProgress: () -> Float, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.fillMaxWidth(), + ) { + val trackShape = HedvigTheme.shapes.cornerFull + repeat(count) { index -> + Box( + Modifier + .weight(1f) + .height(4.dp) + .clip(trackShape) + .background(HedvigTheme.colorScheme.surfaceSecondaryTransparent), + ) { + val fill = when { + index < currentIndex -> 1f + index == currentIndex -> currentProgress() + else -> 0f + } + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(fill) + .background(HedvigTheme.colorScheme.fillPrimary), + ) + } + } + } +} + +private const val SLIDE_DURATION_MILLIS = 5_000 + +/** + * Plays [url] muted over the slide's image, which stays visible until the first frame renders. With [loop] + * false it plays once and holds its last frame. + */ +@Composable +internal expect fun CarouselVideo(url: String, loop: Boolean, modifier: Modifier = Modifier) diff --git a/app/feature/feature-product-in-app/src/jvmMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.jvm.kt b/app/feature/feature-product-in-app/src/jvmMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.jvm.kt new file mode 100644 index 0000000000..b1cc9f764b --- /dev/null +++ b/app/feature/feature-product-in-app/src/jvmMain/kotlin/com/hedvig/android/feature/product/inapp/ui/CarouselVideo.jvm.kt @@ -0,0 +1,8 @@ +package com.hedvig.android.feature.product.inapp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal actual fun CarouselVideo(url: String, loop: Boolean, modifier: Modifier) { +} diff --git a/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigationTest.kt b/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigationTest.kt new file mode 100644 index 0000000000..95df4cb8b6 --- /dev/null +++ b/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/navigation/CrossSellProductNavigationTest.kt @@ -0,0 +1,70 @@ +package com.hedvig.android.feature.product.inapp.navigation + +import assertk.assertThat +import assertk.assertions.containsExactly +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.serialization.Serializable +import org.junit.Test + +class CrossSellProductNavigationTest { + private class TestBackstack(vararg initial: HedvigNavKey) : Backstack { + override val entries: MutableList = initial.toMutableList() + } + + @Test + fun `a single product opens its product page`() { + val backstack = TestBackstack(CallerKey) + + backstack.navigateToCrossSellProduct(crossSell("accident")) + + assertThat(backstack.entries).containsExactly(CallerKey, productKey("accident")) + } + + @Test + fun `a generic cross-sell opens the options sheet with each option`() { + val backstack = TestBackstack(CallerKey) + + backstack.navigateToCrossSellProduct(crossSell("pet", options = listOf(crossSell("dog"), crossSell("cat")))) + + assertThat(backstack.entries).containsExactly( + CallerKey, + CrossSellOptionsKey(options = listOf(productKey("dog"), productKey("cat"))), + ) + } + + @Test + fun `choosing an option replaces the sheet with the product page`() { + val sheet = CrossSellOptionsKey(options = listOf(productKey("dog"), productKey("cat"))) + val backstack = TestBackstack(CallerKey, sheet) + + backstack.navigateToSelectedOption(productKey("cat")) + + assertThat(backstack.entries).containsExactly(CallerKey, productKey("cat")) + } +} + +private fun crossSell(id: String, options: List = emptyList()) = CrossSell( + id = id, + title = "$id title", + subtitle = "$id subtitle", + storeUrl = "https://$id", + pillowImageSmall = ImageAsset(id = "$id-small", src = "https://$id-small", description = null), + pillowImageLarge = ImageAsset(id = "$id-large", src = "https://$id-large", description = null), + buttonText = "See price", + options = options, +) + +private fun productKey(id: String) = CrossSellProductKey( + crossSellId = id, + title = "$id title", + subtitle = "$id subtitle", + pillowImageSrc = "https://$id-small", + storeUrl = "https://$id", + discountText = null, +) + +@Serializable +private data object CallerKey : HedvigNavKey diff --git a/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductPresenterTest.kt b/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductPresenterTest.kt new file mode 100644 index 0000000000..da8ba5a95c --- /dev/null +++ b/app/feature/feature-product-in-app/src/jvmTest/kotlin/com/hedvig/android/feature/product/inapp/ui/CrossSellProductPresenterTest.kt @@ -0,0 +1,77 @@ +package com.hedvig.android.feature.product.inapp.ui + +import app.cash.turbine.Turbine +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.hedvig.android.apollo.ApolloOperationError +import com.hedvig.android.data.contract.ImageAsset +import com.hedvig.android.feature.product.inapp.data.CrossSellProductInfo +import com.hedvig.android.feature.product.inapp.data.GetCrossSellProductInfoUseCase +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CrossSellProductPresenterTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + @Test + fun `a successful load shows the product info`() = runTest { + val useCase = FakeGetCrossSellProductInfoUseCase() + val presenter = CrossSellProductPresenter(crossSellId = "id", getCrossSellProductInfoUseCase = useCase) + + presenter.test(CrossSellProductUiState.Loading) { + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Loading) + useCase.results.add(productInfo.right()) + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Content(productInfo)) + assertThat(useCase.requestedIds).isEqualTo(listOf("id")) + } + } + + @Test + fun `a failed load shows the failure state and retry loads again`() = runTest { + val useCase = FakeGetCrossSellProductInfoUseCase() + val presenter = CrossSellProductPresenter(crossSellId = "id", getCrossSellProductInfoUseCase = useCase) + + presenter.test(CrossSellProductUiState.Loading) { + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Loading) + useCase.results.add(ApolloOperationError.OperationError.Other("error").left()) + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Failure) + + sendEvent(CrossSellProductEvent.Retry) + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Loading) + useCase.results.add(productInfo.right()) + assertThat(awaitItem()).isEqualTo(CrossSellProductUiState.Content(productInfo)) + } + } +} + +private val productInfo = CrossSellProductInfo( + heroImage = ImageAsset(id = "hero", src = "https://hero", description = null), + heroIntro = CrossSellProductInfo.HeroIntro(title = "Intro", description = "Intro description"), + usps = listOf(CrossSellProductInfo.Usp(title = "Usp", description = "Usp description")), + carousel = listOf( + CrossSellProductInfo.CarouselItem( + title = "Slide", + subtitle = null, + image = ImageAsset(id = "slide", src = "https://slide", description = null), + videoUrl = null, + loop = false, + ), + ), +) + +private class FakeGetCrossSellProductInfoUseCase : GetCrossSellProductInfoUseCase { + val results = Turbine>() + val requestedIds = mutableListOf() + + override suspend fun invoke(crossSellId: String): Either { + requestedIds.add(crossSellId) + return results.awaitItem() + } +} diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt index 4a77fe052c..045f35a45f 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt @@ -10,5 +10,6 @@ internal val Feature.unleashKey: String Feature.ENABLE_CLAIM_INTENT_RESUME -> "enable_claim_intent_resume" Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX -> "enable_new_conversation_from_inbox" Feature.HIDE_CANCEL_INSURANCE_FROM_HOME_SCREEN -> "hide_cancel_insurance_from_home_screen" + Feature.SHOW_PRODUCT_PAGES -> "show_product_pages" Feature.UPDATE_NECESSARY -> "update_necessary" } diff --git a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt index 62600e5667..c3f081e629 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt @@ -36,6 +36,10 @@ enum class Feature( "on the home screen, so the home screen does not prompt cancellation. The option stays available " + "everywhere else, such as the help center quick actions and insurance details.", ), + SHOW_PRODUCT_PAGES( + "Opens the in-app product page when a cross-sell is tapped, instead of its store URL. Off until " + + "flags are first fetched, so a cross-sell keeps opening its store URL.", + ), UPDATE_NECESSARY( "Defines the lowest supported app version. Should prompt a user to update if it uses an outdated version.", ),