Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 1 addition & 1 deletion examples/paywall-tester/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ android {
namespace = "com.revenuecat.paywallstester"

defaultConfig {
applicationId = "com.revenuecat.paywall_tester"
applicationId = "com.revenuecat.purchases_sample"
minSdk = 24
versionCode = (project.properties["paywallTesterVersionCode"] as String).toInt()
versionName = project.properties["paywallTesterVersionName"] as String
Expand Down
3 changes: 2 additions & 1 deletion examples/paywall-tester/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
android:roundIcon="@mipmap/icon_round"
android:backupAgent="com.revenuecat.purchases.backup.RevenueCatBackupAgent"
android:supportsRtl="true"
android:theme="@style/Theme.Purchasesandroid">
android:theme="@style/Theme.Purchasesandroid"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".PaywallFooterViewActivity"
android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import android.content.Context
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesAreCompletedBy
import com.revenuecat.purchases.PurchasesConfiguration
import java.net.URL

internal class ConfigurePurchasesUseCase(
private val context: Context,
) {

operator fun invoke(apiKey: String) {
Purchases.proxyURL = URL("http://localhost:8000")

val builder = PurchasesConfiguration.Builder(context.applicationContext, apiKey)
.purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
.appUserID(null)
Expand Down
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 @@ -4,6 +4,7 @@ import com.revenuecat.purchases.ColorAlias
import com.revenuecat.purchases.JsonTools
import com.revenuecat.purchases.LogHandler
import com.revenuecat.purchases.common.currentLogHandler
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
import com.revenuecat.purchases.paywalls.components.common.PromoOfferConfig
import com.revenuecat.purchases.paywalls.components.properties.ColorInfo
Expand Down Expand Up @@ -187,6 +188,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(
ComponentOverride(
conditions = listOf(
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
Loading