Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.ComponentOverride
import com.revenuecat.purchases.paywalls.components.common.PromoOfferConfig
import com.revenuecat.purchases.paywalls.components.common.ResilientPromoOfferConfigSerializer
import dev.drewhamilton.poko.Poko
Expand All @@ -26,4 +27,17 @@ public class PackageComponent(
@Serializable(with = ResilientPromoOfferConfigSerializer::class)
@SerialName("play_store_offer")
public val playStoreOffer: PromoOfferConfig? = null,
@get:JvmSynthetic
public val visible: Boolean? = null,
@get:JvmSynthetic
public val overrides: List<ComponentOverride<PartialPackageComponent>> = emptyList(),
) : PaywallComponent

@InternalRevenueCatAPI
@Poko
@Serializable
@Immutable
public class PartialPackageComponent(
@get:JvmSynthetic
public val visible: Boolean? = true,
) : PartialComponent
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,87 @@ internal class PackageComponentTests(@Suppress("UNUSED_PARAMETER") name: String,
)
),
),
arrayOf(
"visible = true",
Args(
json = """
{
"type": "package",
"package_id": "${"$"}rc_weekly",
"is_selected_by_default": true,
"visible": true,
"stack": {
"type": "stack",
"components": []
}
}
""".trimIndent(),
expected = PackageComponent(
packageId = "${"$"}rc_weekly",
isSelectedByDefault = true,
stack = StackComponent(components = emptyList()),
visible = true,
)
),
),
arrayOf(
"visible = false",
Args(
json = """
{
"type": "package",
"package_id": "${"$"}rc_weekly",
"is_selected_by_default": true,
"visible": false,
"stack": {
"type": "stack",
"components": []
}
}
""".trimIndent(),
expected = PackageComponent(
packageId = "${"$"}rc_weekly",
isSelectedByDefault = true,
stack = StackComponent(components = emptyList()),
visible = false,
)
),
),
arrayOf(
"overrides with visible = false",
Args(
json = """
{
"type": "package",
"package_id": "${"$"}rc_weekly",
"is_selected_by_default": true,
"stack": {
"type": "stack",
"components": []
},
"overrides": [
{
"conditions": [{"type": "intro_offer"}],
"properties": {"visible": false}
}
]
}
""".trimIndent(),
expected = PackageComponent(
packageId = "${"$"}rc_weekly",
isSelectedByDefault = true,
stack = StackComponent(components = emptyList()),
overrides = listOf(
com.revenuecat.purchases.paywalls.components.common.ComponentOverride(
conditions = listOf(
com.revenuecat.purchases.paywalls.components.common.ComponentOverride.Condition.IntroOffer
),
properties = PartialPackageComponent(visible = false),
)
),
)
),
),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.revenuecat.purchases.ui.revenuecatui.components

import com.revenuecat.purchases.paywalls.components.PartialPackageComponent
import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError
import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList
import com.revenuecat.purchases.ui.revenuecatui.helpers.Result
import dev.drewhamilton.poko.Poko

@Poko
internal class PresentedPackagePartial(
@get:JvmSynthetic val partial: PartialPackageComponent,
) : PresentedPartial<PresentedPackagePartial> {

companion object {
@JvmSynthetic
operator fun invoke(
from: PartialPackageComponent,
): Result<PresentedPackagePartial, NonEmptyList<PaywallValidationError>> =
Result.Success(PresentedPackagePartial(partial = from))
}

override fun combine(with: PresentedPackagePartial?): PresentedPackagePartial {
val otherPartial = with?.partial
return PresentedPackagePartial(
partial = PartialPackageComponent(
visible = otherPartial?.visible ?: partial.visible,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
@file:JvmSynthetic

package com.revenuecat.purchases.ui.revenuecatui.components.pkg

import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.window.core.layout.WindowWidthSizeClass
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
import com.revenuecat.purchases.ui.revenuecatui.components.ComponentViewState
import com.revenuecat.purchases.ui.revenuecatui.components.ConditionContext
import com.revenuecat.purchases.ui.revenuecatui.components.ScreenCondition
import com.revenuecat.purchases.ui.revenuecatui.components.buildPresentedPartial
import com.revenuecat.purchases.ui.revenuecatui.components.state.PackageAwareDelegate
import com.revenuecat.purchases.ui.revenuecatui.components.style.PackageComponentStyle
import com.revenuecat.purchases.ui.revenuecatui.composables.OfferEligibility
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState

@Stable
@JvmSynthetic
@Composable
internal fun rememberUpdatedPackageComponentState(
style: PackageComponentStyle,
paywallState: PaywallState.Loaded.Components,
): PackageComponentState = rememberUpdatedPackageComponentState(
style = style,
selectedPackageInfoProvider = { paywallState.selectedPackageInfo },
selectedTabIndexProvider = { paywallState.selectedTabIndex },
selectedOfferEligibilityProvider = { paywallState.selectedOfferEligibility },
customVariablesProvider = { paywallState.mergedCustomVariables },
)

@Suppress("LongParameterList")
@Stable
@JvmSynthetic
@Composable
private fun rememberUpdatedPackageComponentState(
style: PackageComponentStyle,
selectedPackageInfoProvider: () -> PaywallState.Loaded.Components.SelectedPackageInfo?,
selectedTabIndexProvider: () -> Int,
selectedOfferEligibilityProvider: () -> OfferEligibility,
customVariablesProvider: () -> Map<String, CustomVariableValue>,
): PackageComponentState {
val windowSize = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass

return remember(style) {
PackageComponentState(
initialWindowSize = windowSize,
style = style,
selectedPackageInfoProvider = selectedPackageInfoProvider,
selectedTabIndexProvider = selectedTabIndexProvider,
selectedOfferEligibilityProvider = selectedOfferEligibilityProvider,
customVariablesProvider = customVariablesProvider,
)
}.apply {
update(windowSize = windowSize)
}
}

@Suppress("LongParameterList")
@Stable
internal class PackageComponentState(
initialWindowSize: WindowWidthSizeClass,
private val style: PackageComponentStyle,
private val selectedPackageInfoProvider: () -> PaywallState.Loaded.Components.SelectedPackageInfo?,
private val selectedTabIndexProvider: () -> Int,
private val selectedOfferEligibilityProvider: () -> OfferEligibility,
private val customVariablesProvider: () -> Map<String, CustomVariableValue> = { emptyMap() },
) {
private var windowSize by mutableStateOf(initialWindowSize)

private val packageAwareDelegate = PackageAwareDelegate(
style = style,
selectedPackageInfoProvider = selectedPackageInfoProvider,
selectedTabIndexProvider = selectedTabIndexProvider,
selectedOfferEligibilityProvider = selectedOfferEligibilityProvider,
)

private val presentedPartial by derivedStateOf {
val windowCondition = ScreenCondition.from(windowSize)
val componentState =
if (packageAwareDelegate.isSelected) ComponentViewState.SELECTED else ComponentViewState.DEFAULT

style.overrides.buildPresentedPartial(
windowCondition,
packageAwareDelegate.offerEligibility,
componentState,
conditionContext = ConditionContext(
selectedPackageId = selectedPackageInfoProvider()?.rcPackage?.identifier,
customVariables = customVariablesProvider(),
),
)
}

@get:JvmSynthetic
val visible by derivedStateOf { presentedPartial?.partial?.visible ?: style.visible }

@JvmSynthetic
fun update(windowSize: WindowWidthSizeClass) {
this.windowSize = windowSize
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package com.revenuecat.purchases.ui.revenuecatui.components.pkg

import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction
import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView
Expand All @@ -19,6 +21,25 @@ internal fun PackageComponentView(
clickHandler: suspend (PaywallAction) -> Unit,
modifier: Modifier = Modifier,
) {
val packageState = rememberUpdatedPackageComponentState(style = style, paywallState = state)

// Notify PaywallState whenever this package's visibility changes so that purchase / pricing
// state never references a hidden package. These effects run before the early-return so they
// remain active even while invisible.
LaunchedEffect(packageState.visible) {
state.setPackageVisible(uniqueId = style.uniqueId, isVisible = packageState.visible)
}
DisposableEffect(style.uniqueId) {
onDispose {
// Clear rather than mark-false so that packages leaving one tab (and potentially
// re-entering in another) are treated as unknown rather than hidden, preventing
// reconcileSelectedIfHidden from evicting the selection prematurely.
state.clearPackageVisible(uniqueId = style.uniqueId)
}
}

if (!packageState.visible) return

StackComponentView(
style = style.stackComponentStyle,
state = state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,41 @@ 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.components.PresentedOverride
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedPackagePartial
import com.revenuecat.purchases.ui.revenuecatui.components.state.PackageContext
import com.revenuecat.purchases.ui.revenuecatui.composables.OfferEligibility
import com.revenuecat.purchases.ui.revenuecatui.helpers.ResolvedOffer

@Suppress("LongParameterList")
@Immutable
internal data class PackageComponentStyle(
@get:JvmSynthetic
val rcPackage: Package,
override val rcPackage: Package,
@get:JvmSynthetic
val isSelectedByDefault: Boolean,
@get:JvmSynthetic
val stackComponentStyle: StackComponentStyle,
@get:JvmSynthetic
val isSelectable: Boolean,
@get:JvmSynthetic
override val resolvedOffer: ResolvedOffer? = null,
@get:JvmSynthetic
override val visible: Boolean,
@get:JvmSynthetic
val overrides: List<PresentedOverride<PresentedPackagePartial>>,
/**
* The resolved Play Store offer for this package, if configured.
* Used for purchase flow and template variables.
* Pre-computed offer eligibility for this package, used for evaluating intro/promo offer conditions
* in package-level overrides.
*/
@get:JvmSynthetic
val resolvedOffer: ResolvedOffer? = null,
) : ComponentStyle {
override val visible: Boolean = stackComponentStyle.visible
override val offerEligibility: OfferEligibility? = null,
) : ComponentStyle, PackageContext {
override val size: Size = stackComponentStyle.size

@get:JvmSynthetic
override val tabIndex: Int? = null

/**
* Unique identifier for this package component, combining package ID and offer ID.
* This allows distinguishing between multiple components that reference the same package
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.LocalizedTextPartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedCarouselPartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedIconPartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedImagePartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedPackagePartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedStackPartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedTabsPartial
import com.revenuecat.purchases.ui.revenuecatui.components.PresentedTimelineItemPartial
Expand Down Expand Up @@ -566,26 +567,43 @@ internal class StyleFactory(
withSelectedScope(
packageInfo = AvailablePackages.Info(
pkg = rcPackage,
isSelectedByDefault = component.isSelectedByDefault,
// A statically hidden package must never drive the initial selection, even if
// isSelectedByDefault is true. Dynamic override visibility is reconciled at
// runtime via PackageComponentView reporting visibility to PaywallState.
isSelectedByDefault = component.isSelectedByDefault && component.visible != false,
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.
tabControlIndex = null,
) {
val packageOfferEligibility = offerEligibility

val presentedOverridesResult = component.overrides
.toPresentedOverrides(stripRules) { partial ->
PresentedPackagePartial(from = partial)
}
.mapError { nonEmptyListOf(it) }

val (stackComponentStyleResult, purchaseButtons) = withCount(
predicate = { it is PurchaseButtonComponent },
) {
createStackComponentStyle(component.stack)
}

stackComponentStyleResult.map { stack ->
zipOrAccumulate(
first = presentedOverridesResult,
second = stackComponentStyleResult,
) { presentedOverrides, stack ->
PackageComponentStyle(
stackComponentStyle = stack,
rcPackage = rcPackage,
isSelectedByDefault = component.isSelectedByDefault,
isSelectable = purchaseButtons == 0,
resolvedOffer = resolvedOffer,
visible = component.visible ?: DEFAULT_VISIBILITY,
overrides = presentedOverrides,
offerEligibility = packageOfferEligibility,
)
}
}
Expand Down
Loading