Skip to content

Commit

Permalink
Paywalls: Add API to display paywall as a composable dialog (#1297)
Browse files Browse the repository at this point in the history
### 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.
  • Loading branch information
tonidero authored Oct 3, 2023
1 parent bc072d1 commit 13af808
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
}
}
Expand All @@ -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<Offering?>(null) }
var displayPaywallDialogOffering by remember { mutableStateOf<Offering?>(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)
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }

Expand Down
2 changes: 2 additions & 0 deletions ui/revenuecatui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ private fun getPaywallViewModel(
): PaywallViewModel {
val applicationContext = LocalContext.current.applicationContext
return viewModel<PaywallViewModelImpl>(
// 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ interface PaywallViewListener {
fun onRestoreStarted() {}
fun onRestoreCompleted(customerInfo: CustomerInfo) {}
fun onRestoreError(error: PurchasesError) {}
fun onDismissed() {}
fun onCloseButtonPressed() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class PaywallViewOptions(builder: Builder) {
this.shouldDisplayDismissButton = shouldDisplayDismissButton
}

fun setListener(listener: PaywallViewListener) = apply {
fun setListener(listener: PaywallViewListener?) = apply {
this.listener = listener
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading

0 comments on commit 13af808

Please sign in to comment.