From 13af80863dbcce4082f918bc9b358797b909b3ba Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Tue, 3 Oct 2023 12:27:18 +0200 Subject: [PATCH] Paywalls: Add API to display paywall as a composable dialog (#1297) ### Description This adds a new composable `PaywallDialog` that can be used to display the paywall as a dialog. It also modifies paywall tester to provide options on how we want to display the paywall from the offerings tab. --- .../screens/main/offerings/OfferingsScreen.kt | 89 ++++++++++++++++--- gradle/libs.versions.toml | 4 + ui/revenuecatui/build.gradle | 2 + .../ui/revenuecatui/InternalPaywallView.kt | 3 + .../ui/revenuecatui/PaywallDialog.kt | 87 ++++++++++++++++++ .../ui/revenuecatui/PaywallDialogOptions.kt | 72 +++++++++++++++ .../ui/revenuecatui/PaywallViewListener.kt | 2 +- .../ui/revenuecatui/PaywallViewOptions.kt | 2 +- .../ui/revenuecatui/helpers/WindowHelper.kt | 20 +++++ .../ui/revenuecatui/templates/Template1.kt | 4 +- .../ui/revenuecatui/templates/Template2.kt | 13 ++- 11 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/WindowHelper.kt diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt index 7b678b517c..daf5ccbe1d 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt @@ -2,6 +2,7 @@ package com.revenuecat.paywallstester.ui.screens.main.offerings import androidx.compose.foundation.clickable 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.fillMaxWidth @@ -10,15 +11,26 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Divider import androidx.compose.material.Text +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Offerings +import com.revenuecat.purchases.models.StoreTransaction +import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog +import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallViewListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,7 +42,10 @@ fun OfferingsScreen( ) { when (val state = viewModel.offeringsState.collectAsState().value) { is OfferingsState.Error -> ErrorOfferingsScreen(errorState = state) - is OfferingsState.Loaded -> OfferingsListScreen(offeringsState = state, tappedOnOffering = tappedOnOffering) + is OfferingsState.Loaded -> OfferingsListScreen( + offeringsState = state, + tappedOnNavigateToOffering = tappedOnOffering, + ) OfferingsState.Loading -> LoadingOfferingsScreen() } } @@ -56,22 +71,74 @@ private fun LoadingOfferingsScreen() { } @Composable -private fun OfferingsListScreen(offeringsState: OfferingsState.Loaded, tappedOnOffering: (Offering) -> Unit) { +private fun OfferingsListScreen( + offeringsState: OfferingsState.Loaded, + tappedOnNavigateToOffering: (Offering) -> Unit, +) { + var dropdownExpandedOffering by remember { mutableStateOf(null) } + var displayPaywallDialogOffering by remember { mutableStateOf(null) } + LazyColumn { items(offeringsState.offerings.all.values.toList()) { offering -> - Column(modifier = Modifier.fillMaxWidth()) { - Row( - Modifier - .fillMaxWidth() - .clickable { tappedOnOffering(offering) } - .padding(16.dp), - ) { - Text(text = offering.identifier) + Box(modifier = Modifier.fillMaxWidth()) { + if (offering == dropdownExpandedOffering) { + DisplayOfferingMenu( + offering = offering, + tappedOnNavigateToOffering = tappedOnNavigateToOffering, + tappedOnDisplayOfferingAsDialog = { displayPaywallDialogOffering = it }, + dismissed = { dropdownExpandedOffering = null }, + ) + } + Column(modifier = Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .clickable { dropdownExpandedOffering = offering } + .padding(16.dp), + ) { + Text(text = offering.identifier) + } + Divider() } - Divider() } } } + + if (displayPaywallDialogOffering != null) { + PaywallDialog( + PaywallDialogOptions.Builder( + dismissRequest = { + displayPaywallDialogOffering = null + }, + ) + .setOffering(displayPaywallDialogOffering) + .setListener(object : PaywallViewListener { + override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) { + displayPaywallDialogOffering = null + } + }) + .build(), + ) + } +} + +@Composable +private fun DisplayOfferingMenu( + offering: Offering, + tappedOnNavigateToOffering: (Offering) -> Unit, + tappedOnDisplayOfferingAsDialog: (Offering) -> Unit, + dismissed: () -> Unit, +) { + DropdownMenu(expanded = true, onDismissRequest = { dismissed() }) { + DropdownMenuItem( + text = { Text(text = "Navigate to paywall") }, + onClick = { tappedOnNavigateToOffering(offering) }, + ) + DropdownMenuItem( + text = { Text(text = "Display paywall as dialog") }, + onClick = { tappedOnDisplayOfferingAsDialog(offering) }, + ) + } } @Preview(showBackground = true) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7262fe45e6..61576ff4b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ adsIdentifier = "17.0.1" viewmodelCompose = "2.4.0" paparrazzi = "1.3.1" coil = "2.4.0" +window = "1.1.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -112,6 +113,9 @@ compose-material3 = { module = "androidx.compose.material3:material3" } compose-window-size = { module = "androidx.compose.material3:material3-window-size-class" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +window = { module = "androidx.window:window", version.ref = "window" } +window-core = { module = "androidx.window:window-core", version.ref = "window" } + coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } diff --git a/ui/revenuecatui/build.gradle b/ui/revenuecatui/build.gradle index a9dda0f075..a25e12af90 100644 --- a/ui/revenuecatui/build.gradle +++ b/ui/revenuecatui/build.gradle @@ -44,6 +44,8 @@ dependencies { implementation libs.compose.ui.tooling.preview implementation libs.compose.material implementation libs.compose.material3 + implementation libs.window + implementation libs.window.core implementation libs.androidx.lifecycle.runtime.ktx implementation libs.androidx.lifecycle.viewmodel implementation libs.androidx.lifecycle.viewmodel.compose diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywallView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywallView.kt index 55c75bccbc..89a4fd8eac 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywallView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywallView.kt @@ -56,6 +56,9 @@ private fun getPaywallViewModel( ): PaywallViewModel { val applicationContext = LocalContext.current.applicationContext return viewModel( + // We need to pass the key in order to create different view models for different offerings when + // trying to load different paywalls for the same view model store owner. + key = offering?.identifier, factory = PaywallViewModelFactory( applicationContext.toAndroidContext(), mode, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt new file mode 100644 index 0000000000..ce0a69cd63 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt @@ -0,0 +1,87 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.window.core.layout.WindowWidthSizeClass +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.awaitCustomerInfo +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.helpers.computeWindowWidthSizeClass +import kotlinx.coroutines.launch + +/** + * Composable offering a dialog screen Paywall UI configured from the RevenueCat dashboard. + * This dialog will be shown as a full screen dialog in compact devices and a normal dialog othewise. + * @param paywallDialogOptions The options to configure the PaywallDialog and what to do on dismissal. + */ +@Composable +fun PaywallDialog( + paywallDialogOptions: PaywallDialogOptions, +) { + val shouldDisplayBlock = paywallDialogOptions.shouldDisplayBlock + var shouldDisplayDialog by remember { mutableStateOf(shouldDisplayBlock == null) } + if (shouldDisplayBlock != null) { + LaunchedEffect(paywallDialogOptions) { + launch { + shouldDisplayDialog = try { + // TODO-PAYWALLS: This won't receive updates in case the customer info changes and starts/stops + // being eligible to display the paywall dialog. We would need to support multiple customer info + // listeners to refresh this. + val customerInfo = Purchases.sharedInstance.awaitCustomerInfo() + shouldDisplayBlock.invoke(customerInfo) + } catch (e: PurchasesException) { + Logger.e("Error fetching customer info to display paywall dialog", e) + false + } + if (shouldDisplayDialog) { + Logger.d("Displaying paywall dialog according to display logic") + } else { + Logger.d("Not displaying paywall dialog according to display logic") + } + } + } + } + if (shouldDisplayDialog) { + Dialog( + onDismissRequest = paywallDialogOptions.dismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = shouldUsePlatformDefaultWidth()), + ) { + DialogScaffold(paywallDialogOptions) + } + } +} + +@Composable +fun DialogScaffold(paywallDialogOptions: PaywallDialogOptions) { + Scaffold(modifier = Modifier.fillMaxSize()) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + PaywallView(paywallDialogOptions.toPaywallViewOptions()) + } + } +} + +@Composable +@ReadOnlyComposable +private fun shouldUsePlatformDefaultWidth(): Boolean { + return when (computeWindowWidthSizeClass()) { + WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.EXPANDED -> true + else -> false + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions.kt new file mode 100644 index 0000000000..d45e9e5e61 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions.kt @@ -0,0 +1,72 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Offering + +class PaywallDialogOptions(builder: Builder) { + + val dismissRequest: () -> Unit + val shouldDisplayBlock: ((CustomerInfo) -> Boolean)? + val offering: Offering? + val shouldDisplayDismissButton: Boolean + val listener: PaywallViewListener? + + init { + this.shouldDisplayBlock = builder.shouldDisplayBlock + this.dismissRequest = builder.dismissRequest + this.offering = builder.offering + this.shouldDisplayDismissButton = builder.shouldDisplayDismissButton + this.listener = builder.listener + } + + fun toPaywallViewOptions(): PaywallViewOptions { + return PaywallViewOptions.Builder() + .setOffering(offering) + .setShouldDisplayDismissButton(shouldDisplayDismissButton) + .setListener(listener) + .build() + } + + class Builder( + val dismissRequest: () -> Unit, + ) { + internal var shouldDisplayBlock: ((CustomerInfo) -> Boolean)? = null + internal var offering: Offering? = null + internal var shouldDisplayDismissButton: Boolean = true + internal var listener: PaywallViewListener? = null + + /** + * Allows to configure whether to display the paywall dialog depending on operations on the CustomerInfo + */ + fun setShouldDisplayBlock(shouldDisplayBlock: ((CustomerInfo) -> Boolean)?) = apply { + this.shouldDisplayBlock = shouldDisplayBlock + } + + /** + * Allows to configure whether to display the paywall dialog depending on the presence of a specific entitlement + */ + fun setRequiredEntitlementIdentifier(requiredEntitlementIdentifier: String?) = apply { + requiredEntitlementIdentifier?.let { requiredEntitlementIdentifier -> + this.shouldDisplayBlock = { customerInfo -> + customerInfo.entitlements[requiredEntitlementIdentifier]?.isActive == true + } + } + } + + fun setOffering(offering: Offering?) = apply { + this.offering = offering + } + + fun setShouldDisplayDismissButton(shouldDisplayDismissButton: Boolean) = apply { + this.shouldDisplayDismissButton = shouldDisplayDismissButton + } + + fun setListener(listener: PaywallViewListener?) = apply { + this.listener = listener + } + + fun build(): PaywallDialogOptions { + return PaywallDialogOptions(this) + } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewListener.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewListener.kt index 57ff35e1b6..0da6bea5e1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewListener.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewListener.kt @@ -12,5 +12,5 @@ interface PaywallViewListener { fun onRestoreStarted() {} fun onRestoreCompleted(customerInfo: CustomerInfo) {} fun onRestoreError(error: PurchasesError) {} - fun onDismissed() {} + fun onCloseButtonPressed() {} } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewOptions.kt index 7fbc3f1c6c..dcd791e542 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewOptions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallViewOptions.kt @@ -27,7 +27,7 @@ class PaywallViewOptions(builder: Builder) { this.shouldDisplayDismissButton = shouldDisplayDismissButton } - fun setListener(listener: PaywallViewListener) = apply { + fun setListener(listener: PaywallViewListener?) = apply { this.listener = listener } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/WindowHelper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/WindowHelper.kt new file mode 100644 index 0000000000..969126bca2 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/WindowHelper.kt @@ -0,0 +1,20 @@ +package com.revenuecat.purchases.ui.revenuecatui.helpers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalContext +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.layout.WindowMetricsCalculator +import com.revenuecat.purchases.ui.revenuecatui.extensions.getActivity + +@Composable +@ReadOnlyComposable +internal fun computeWindowWidthSizeClass(): WindowWidthSizeClass? { + val activity = LocalContext.current.getActivity() ?: return null + val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity) + val width = metrics.bounds.width() + val height = metrics.bounds.height() + val density = LocalContext.current.resources.displayMetrics.density + return WindowSizeClass.compute(width / density, height / density).windowWidthSizeClass +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template1.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template1.kt index 40773628b1..5bd50e7841 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template1.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template1.kt @@ -51,7 +51,9 @@ internal fun Template1(state: PaywallViewState.Loaded, viewModel: PaywallViewMod modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Template1MainContent(state) + Column(modifier = Modifier.weight(1f)) { + Template1MainContent(state) + } PurchaseButton(state, viewModel) Footer(templateConfiguration = state.templateConfiguration, viewModel = viewModel) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template2.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template2.kt index 55e79e88c2..9723024bab 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template2.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template2.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -47,9 +46,15 @@ internal fun Template2(state: PaywallViewState.Loaded, viewModel: PaywallViewMod Column( modifier = Modifier.fillMaxSize(), ) { - Spacer(modifier = Modifier.weight(1f)) - Template2MainContent(state, viewModel) - Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier.weight(1f).padding( + horizontal = UIConstant.defaultHorizontalPadding, + vertical = UIConstant.defaultVerticalSpacing, + ), + contentAlignment = Alignment.Center, + ) { + Template2MainContent(state, viewModel) + } PurchaseButton(state, viewModel) Footer(templateConfiguration = state.templateConfiguration, viewModel = viewModel) }