Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.components

import androidx.compose.runtime.Immutable
import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.paywalls.components.common.PlayStoreOfferConfig
import dev.drewhamilton.poko.Poko
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand All @@ -20,4 +21,7 @@ class PackageComponent(
val isSelectedByDefault: Boolean,
@get:JvmSynthetic
val stack: StackComponent,
@get:JvmSynthetic
@SerialName("play_store_offer")
val playStoreOffer: PlayStoreOfferConfig? = null,
) : PaywallComponent
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ class PaywallComponentsData(
@get:JvmSynthetic
@SerialName("exit_offers")
val exitOffers: ExitOffers? = null,
@get:JvmSynthetic
@SerialName("product_change")
val productChangeConfig: ProductChangeConfig? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.revenuecat.purchases.paywalls.components.common

import com.revenuecat.purchases.InternalRevenueCatAPI
import dev.drewhamilton.poko.Poko
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Configuration for a specific Play Store Offer to use for a package in a paywall.
* Similar to iOS's applePromoOfferProductCode.
*/
@InternalRevenueCatAPI
@Poko
@Serializable
class PlayStoreOfferConfig(
/**
* The Play Store offer identifier to use for this package.
* This should match the offer ID configured in Google Play Console.
*/
@get:JvmSynthetic
@SerialName("offer_id")
val offerId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.revenuecat.purchases.paywalls.components.common

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.models.GoogleReplacementMode
import dev.drewhamilton.poko.Poko
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Configuration for product changes (upgrades/downgrades) in a paywall.
*
* This configuration is used when a user with an active subscription purchases a different
* product that grants the same entitlement. Without this configuration, such purchases
* result in parallel subscriptions.
*/
@InternalRevenueCatAPI
@Poko
@Serializable
class ProductChangeConfig(
/**
* Replacement mode to use for upgrades (moving to a higher price per unit time).
* Defaults to CHARGE_PRORATED_PRICE as recommended by Google.
*/
@get:JvmSynthetic
@SerialName("upgrade_replacement_mode")
val upgradeReplacementMode: SerializableReplacementMode = SerializableReplacementMode.CHARGE_PRORATED_PRICE,

/**
* Replacement mode to use for downgrades (moving to a lower price per unit time).
* Defaults to DEFERRED.
*/
@get:JvmSynthetic
@SerialName("downgrade_replacement_mode")
val downgradeReplacementMode: SerializableReplacementMode = SerializableReplacementMode.DEFERRED,
)

/**
* Serializable replacement mode enum for paywall configuration.
* Maps to [GoogleReplacementMode] for actual purchase operations.
*/
@InternalRevenueCatAPI
@Serializable
enum class SerializableReplacementMode {
/**
* Old subscription is cancelled, and new subscription takes effect immediately.
* User is charged for the full price of the new subscription on the old subscription's
* expiration date.
*/
@SerialName("without_proration")
WITHOUT_PRORATION,

/**
* Old subscription is cancelled, and new subscription takes effect immediately.
* Any time remaining on the old subscription is used to push out the first payment date
* for the new subscription.
*/
@SerialName("with_time_proration")
WITH_TIME_PRORATION,

/**
* Replacement takes effect immediately, and the user is charged full price of new plan
* and is given a full billing cycle of subscription, plus remaining prorated time from
* the old plan.
*/
@SerialName("charge_full_price")
CHARGE_FULL_PRICE,

/**
* Replacement takes effect immediately, and the billing cycle remains the same.
* The price difference is charged immediately.
* Only available for upgrades.
*/
@SerialName("charge_prorated_price")
CHARGE_PRORATED_PRICE,

/**
* Replacement takes effect when the old plan expires, and the new price will be charged
* at the same time.
*/
@SerialName("deferred")
DEFERRED,
;

/**
* Converts this serializable replacement mode to the corresponding [GoogleReplacementMode].
*/
@InternalRevenueCatAPI
fun toGoogleReplacementMode(): GoogleReplacementMode = when (this) {
WITHOUT_PRORATION -> GoogleReplacementMode.WITHOUT_PRORATION
WITH_TIME_PRORATION -> GoogleReplacementMode.WITH_TIME_PRORATION
CHARGE_FULL_PRICE -> GoogleReplacementMode.CHARGE_FULL_PRICE
CHARGE_PRORATED_PRICE -> GoogleReplacementMode.CHARGE_PRORATED_PRICE
DEFERRED -> GoogleReplacementMode.DEFERRED
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.revenuecat.purchases.ui.revenuecatui.components.style
import androidx.compose.runtime.Immutable
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.paywalls.components.properties.Size
import com.revenuecat.purchases.ui.revenuecatui.helpers.ResolvedOffer

@Immutable
internal data class PackageComponentStyle(
Expand All @@ -14,6 +15,12 @@ internal data class PackageComponentStyle(
val stackComponentStyle: StackComponentStyle,
@get:JvmSynthetic
val isSelectable: Boolean,
/**
* The resolved Play Store offer for this package, if configured.
* Used for purchase flow and template variables.
*/
@get:JvmSynthetic
val resolvedOffer: ResolvedOffer? = null,
) : ComponentStyle {
override val visible: Boolean = stackComponentStyle.visible
override val size: Size = stackComponentStyle.size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.revenuecat.purchases.ColorAlias
import com.revenuecat.purchases.FontAlias
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.paywalls.components.ButtonComponent
import com.revenuecat.purchases.paywalls.components.CarouselComponent
import com.revenuecat.purchases.paywalls.components.CountdownComponent
Expand Down Expand Up @@ -70,6 +71,7 @@ import com.revenuecat.purchases.ui.revenuecatui.extensions.toPageControlStyles
import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger
import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList
import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyMap
import com.revenuecat.purchases.ui.revenuecatui.helpers.PlayStoreOfferResolver
import com.revenuecat.purchases.ui.revenuecatui.helpers.Result
import com.revenuecat.purchases.ui.revenuecatui.helpers.errorIfNull
import com.revenuecat.purchases.ui.revenuecatui.helpers.flatMap
Expand Down Expand Up @@ -244,6 +246,8 @@ internal class StyleFactory(
var defaultTabIndex: Int? = null
val rcPackage: Package?
get() = packageInfo?.pkg
val subscriptionOption: SubscriptionOption?
get() = packageInfo?.resolvedOffer?.subscriptionOption

private val packagesOutsideTabs = mutableListOf<AvailablePackages.Info>()
private val packagesByTab = mutableMapOf<Int, MutableList<AvailablePackages.Info>>()
Expand Down Expand Up @@ -520,10 +524,18 @@ internal class StyleFactory(
Logger.w(error.message)
return Result.Success(null)
}

// Resolve Play Store offer if configured
val resolvedOffer = PlayStoreOfferResolver.resolve(
rcPackage = rcPackage,
offerConfig = component.playStoreOffer,
)

withSelectedScope(
packageInfo = AvailablePackages.Info(
pkg = rcPackage,
isSelectedByDefault = component.isSelectedByDefault,
resolvedOffer = resolvedOffer,
),
// If a tab control contains a package, which is already an edge case, the package should not
// visually become "selected" if its tab control parent is.
Expand All @@ -541,6 +553,7 @@ internal class StyleFactory(
rcPackage = rcPackage,
isSelectedByDefault = component.isSelectedByDefault,
isSelectable = purchaseButtons == 0,
resolvedOffer = resolvedOffer,
)
}
}
Expand Down Expand Up @@ -770,6 +783,7 @@ internal class StyleFactory(
padding = component.padding.toPaddingValues(),
margin = component.margin.toPaddingValues(),
rcPackage = rcPackage,
subscriptionOption = subscriptionOption,
tabIndex = tabControlIndex,
countdownDate = countdownDate,
countFrom = countFrom,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.paywalls.components.CountdownComponent
import com.revenuecat.purchases.paywalls.components.common.LocaleId
import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey
Expand Down Expand Up @@ -51,6 +52,13 @@ internal class TextComponentStyle(
*/
@get:JvmSynthetic
val rcPackage: Package?,
/**
* The subscription option to use for offer variables (product.offer_*, product.secondary_offer_*).
* When set, offer variables will use pricing phases from this option instead of defaultOption.
* This is typically set when a specific Play Store offer is configured for the package.
*/
@get:JvmSynthetic
val subscriptionOption: SubscriptionOption? = null,
/**
* If this is non-null and equal to the currently selected tab index, the `selected` [overrides] will be used if
* available. This should only be set for texts inside tab control elements. Not for all texts within a tab.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.window.core.layout.WindowWidthSizeClass
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.paywalls.components.CountdownComponent
import com.revenuecat.purchases.ui.revenuecatui.components.ComponentViewState
import com.revenuecat.purchases.ui.revenuecatui.components.ScreenCondition
Expand Down Expand Up @@ -44,6 +45,7 @@ internal fun rememberUpdatedTextComponentState(
style = style,
localeProvider = { paywallState.locale },
selectedPackageProvider = { paywallState.selectedPackageInfo?.rcPackage },
selectedSubscriptionOptionProvider = { paywallState.selectedPackageInfo?.resolvedOffer?.subscriptionOption },
selectedTabIndexProvider = { paywallState.selectedTabIndex },
)
}
Expand All @@ -55,6 +57,7 @@ internal fun rememberUpdatedTextComponentState(
style: TextComponentStyle,
localeProvider: () -> Locale,
selectedPackageProvider: () -> Package?,
selectedSubscriptionOptionProvider: () -> SubscriptionOption? = { null },
selectedTabIndexProvider: () -> Int,
): TextComponentState {
val windowSize = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
Expand All @@ -70,6 +73,7 @@ internal fun rememberUpdatedTextComponentState(
style = style,
localeProvider = localeProvider,
selectedPackageProvider = selectedPackageProvider,
selectedSubscriptionOptionProvider = selectedSubscriptionOptionProvider,
selectedTabIndexProvider = selectedTabIndexProvider,
)
}.apply {
Expand All @@ -86,6 +90,7 @@ internal class TextComponentState(
private val style: TextComponentStyle,
private val localeProvider: () -> Locale,
private val selectedPackageProvider: () -> Package?,
private val selectedSubscriptionOptionProvider: () -> SubscriptionOption?,
private val selectedTabIndexProvider: () -> Int,
) {
private var windowSize by mutableStateOf(initialWindowSize)
Expand Down Expand Up @@ -118,6 +123,15 @@ internal class TextComponentState(
style.rcPackage ?: selectedPackageProvider()
}

/**
* The subscription option to use for offer variables (product.offer_*, product.secondary_offer_*).
* If a specific Play Store offer is configured for this text's package, use that.
* Otherwise, use the selected package's resolved subscription option.
*/
val subscriptionOption: SubscriptionOption? by derivedStateOf {
style.subscriptionOption ?: selectedSubscriptionOptionProvider()
}

/**
* How countdown variables should be displayed (component hours vs total hours).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ private fun rememberProcessedText(
date = state.currentDate,
countdownTime = textState.countdownTime,
countFrom = textState.countFrom,
subscriptionOption = textState.subscriptionOption,
)
} ?: textState.text
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfigura
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider
import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger
import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptySet
import com.revenuecat.purchases.ui.revenuecatui.helpers.ResolvedOffer
import com.revenuecat.purchases.ui.revenuecatui.helpers.createLocaleFromString
import com.revenuecat.purchases.ui.revenuecatui.isFullScreen
import java.util.Date
Expand Down Expand Up @@ -111,6 +112,7 @@ internal sealed interface PaywallState {
data class Info(
val pkg: Package,
val isSelectedByDefault: Boolean,
val resolvedOffer: ResolvedOffer? = null,
)

/**
Expand All @@ -130,6 +132,7 @@ internal sealed interface PaywallState {

data class SelectedPackageInfo(
val rcPackage: Package,
val resolvedOffer: ResolvedOffer? = null,
)

private val initialSelectedPackageOutsideTabs = packages.packagesOutsideTabs
Expand Down Expand Up @@ -210,10 +213,16 @@ internal sealed interface PaywallState {

val selectedPackageInfo by derivedStateOf {
selectedPackage?.let { rcPackage ->
SelectedPackageInfo(rcPackage = rcPackage)
val resolvedOffer = findPackageInfo(rcPackage)?.resolvedOffer
SelectedPackageInfo(rcPackage = rcPackage, resolvedOffer = resolvedOffer)
}
}

private fun findPackageInfo(pkg: Package): AvailablePackages.Info? {
return packages.packagesOutsideTabs.find { it.pkg == pkg }
?: packages.packagesByTab.values.flatten().find { it.pkg == pkg }
}

val mostExpensivePricePerMonthMicros by derivedStateOf {
(packages.packagesOutsideTabs + packages.packagesByTab[selectedTabIndex].orEmpty())
.mostExpensivePricePerMonthMicros()
Expand Down
Loading