Skip to content

Commit

Permalink
[Paywalls V2] Moves validation logic to the Loading phase (#2007)
Browse files Browse the repository at this point in the history
  • Loading branch information
JayShortway authored Dec 30, 2024
1 parent 842e85f commit 178b07a
Show file tree
Hide file tree
Showing 20 changed files with 780 additions and 291 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponent
import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle
import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow
import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf
import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.validate
import kotlinx.coroutines.launch
import java.net.URL

Expand Down Expand Up @@ -148,5 +151,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components {
paywallComponents = data,
)

return PaywallState.Loaded.Components(offering, data)
return offering.toComponentsPaywallState(data.validate().getOrThrow())
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentS
import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull
import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow
import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf
import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.validate
import java.net.URL

@Suppress("LongMethod")
Expand Down Expand Up @@ -336,5 +339,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components {
paywallComponents = data,
)

return PaywallState.Loaded.Components(offering, data)
return offering.toComponentsPaywallState(data.validate().getOrThrow())
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessor
import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull
import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow
import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf
import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.toResourceProvider
import com.revenuecat.purchases.ui.revenuecatui.helpers.validate
import java.net.URL

@Composable
Expand Down Expand Up @@ -414,5 +417,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components {
paywallComponents = data,
)

return PaywallState.Loaded.Components(offering, data)
return offering.toComponentsPaywallState(data.validate().getOrThrow())
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.intl.LocaleList
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.paywalls.components.common.Background
import com.revenuecat.purchases.paywalls.components.common.LocaleId
import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toLocaleId
import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle
import com.revenuecat.purchases.ui.revenuecatui.data.processed.ProcessedLocalizedConfiguration
import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration
import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger
import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptySet
import com.revenuecat.purchases.ui.revenuecatui.isFullScreen
import android.os.LocaleList as FrameworkLocaleList

Expand Down Expand Up @@ -58,10 +60,17 @@ internal sealed interface PaywallState {
}
}

@Suppress("LongParameterList")
@Stable
class Components(
val stack: ComponentStyle,
val stickyFooter: ComponentStyle?,
val background: Background,
override val offering: Offering,
val data: PaywallComponentsData,
/**
* All locales that this paywall supports, with `locales.head` being the default one.
*/
private val locales: NonEmptySet<LocaleId>,
initialLocaleList: LocaleList = LocaleList.current,
initialIsEligibleForIntroOffer: Boolean = false,
initialSelectedPackage: Package? = null,
Expand Down Expand Up @@ -90,9 +99,9 @@ internal sealed interface PaywallState {

private fun LocaleList.toLocaleId(): LocaleId =
// Configured locales take precedence over the default one.
map { it.toLocaleId() }.plus(data.defaultLocaleIdentifier)
map { it.toLocaleId() }.plus(locales.head)
// Find the first locale we have a LocalizationDictionary for.
.first { id -> data.componentsLocalizations.containsKey(id) }
.first { id -> locales.contains(id) }

private fun List<Package>.mostExpensivePricePerMonthMicros(): Long? =
asSequence()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult
import com.revenuecat.purchases.ui.revenuecatui.helpers.ResourceProvider
import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.toLegacyPaywallState
import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedLegacyPaywall
import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedPaywall
import com.revenuecat.purchases.ui.revenuecatui.strings.PaywallValidationErrorStrings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -374,13 +374,12 @@ internal class PaywallViewModelImpl(
return PaywallState.Error("No packages available")
}

val validationResult = offering.paywallComponents
// TODO Actually validate the PaywallComponentsData
?.let { PaywallValidationResult.Components(displayablePaywall = it) }
?: offering.validatedLegacyPaywall(colorScheme, resourceProvider)
val validationResult = offering.validatedPaywall(colorScheme, resourceProvider)

validationResult.error?.let { validationError ->
Logger.w(validationError.associatedErrorString(offering))
validationResult.errors?.let { validationErrors ->
validationErrors.forEach { error ->
Logger.w(error.associatedErrorString(offering))
}
Logger.w(PaywallValidationErrorStrings.DISPLAYING_DEFAULT)
}

Expand All @@ -398,7 +397,7 @@ internal class PaywallViewModelImpl(
storefrontCountryCode = storefrontCountryCode,
)
is PaywallValidationResult.Components -> offering.toComponentsPaywallState(
validatedPaywallData = validationResult.displayablePaywall,
validationResult = validationResult,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal sealed class PaywallValidationError : Throwable() {
}
is MissingStringLocalization -> message
is MissingImageLocalization -> message
is AllLocalizationsMissing -> message
}
}

Expand All @@ -53,4 +54,10 @@ internal sealed class PaywallValidationError : Throwable() {
PaywallValidationErrorStrings.MISSING_IMAGE_LOCALIZATION_WITH_LOCALE.format(key.value, locale.value)
} ?: PaywallValidationErrorStrings.MISSING_IMAGE_LOCALIZATION.format(key.value)
}
data class AllLocalizationsMissing(
val locale: LocaleId,
) : PaywallValidationError() {
override val message: String =
PaywallValidationErrorStrings.ALL_LOCALIZATIONS_MISSING_FOR_LOCALE.format(locale.value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal class NonEmptyMap<K, out V> private constructor(

constructor(entry: Pair<K, V>, tail: Map<K, V>) : this(entry = mapOf(entry).entries.first(), all = tail + entry)

override val keys: NonEmptySet<K> = NonEmptySet(entry.key, all.keys)

@JvmSynthetic
fun toMap(): Map<K, V> = all

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@file:JvmSynthetic

package com.revenuecat.purchases.ui.revenuecatui.helpers

/**
* A Set that is guaranteed to have at least 1 element. Inspired by Arrow. Use [nonEmptySetOf] or
* [toNonEmptySetOrNull] to construct.
*/
internal class NonEmptySet<out A> private constructor(
@get:JvmSynthetic
val head: A,
private val all: Set<A>,
) : Set<A> by all {

constructor(head: A, rest: Iterable<A>) : this(head, all = rest.toSet() + head)

@JvmSynthetic
fun toSet(): Set<A> = all

@JvmSynthetic
override fun isEmpty(): Boolean = false

override fun equals(other: Any?): Boolean = when (other) {
is NonEmptySet<*> -> this.all == other.all
else -> this.all == other
}

override fun hashCode(): Int = all.hashCode()

override fun toString(): String =
"NonEmptySet(${all.joinToString()})"
}

@JvmSynthetic
internal fun <A> nonEmptySetOf(head: A, vararg t: A): NonEmptySet<A> =
NonEmptySet(head, t.asIterable())

@JvmSynthetic
internal fun <A> Iterable<A>.toNonEmptySetOrNull(): NonEmptySet<A>? {
val iterator = iterator()
if (!iterator.hasNext()) return null
return NonEmptySet(iterator.next(), Iterable { iterator })
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
@file:Suppress("TooManyFunctions")

package com.revenuecat.purchases.ui.revenuecatui.helpers

import androidx.compose.material3.ColorScheme
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.paywalls.PaywallData
import com.revenuecat.purchases.paywalls.components.common.LocalizationData
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData
import com.revenuecat.purchases.ui.revenuecatui.PaywallMode
import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction
import com.revenuecat.purchases.ui.revenuecatui.components.style.StyleFactory
import com.revenuecat.purchases.ui.revenuecatui.composables.PaywallIconName
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
import com.revenuecat.purchases.ui.revenuecatui.data.processed.PackageConfigurationType
Expand All @@ -17,27 +23,30 @@ import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefault
import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefaultForIdentifiers
import com.revenuecat.purchases.ui.revenuecatui.extensions.defaultTemplate
import kotlin.Result
import com.revenuecat.purchases.ui.revenuecatui.helpers.Result as RcResult

@Suppress("ReturnCount")
internal fun Offering.validatedLegacyPaywall(
internal fun Offering.validatedPaywall(
currentColorScheme: ColorScheme,
resourceProvider: ResourceProvider,
): PaywallValidationResult.Legacy {
val paywallData = this.paywall
?: return PaywallValidationResult.Legacy(
PaywallData.createDefault(
availablePackages,
currentColorScheme,
resourceProvider,
),
PaywallData.defaultTemplate,
PaywallValidationError.MissingPaywall,
)
): PaywallValidationResult =
paywallComponents?.validate()?.let { result ->
// We need to either unwrap the success value, or wrap the errors in a fallback Paywall.
when (result) {
is RcResult.Success -> result.value
is RcResult.Error -> fallbackPaywall(currentColorScheme, resourceProvider, errors = result.value)
}
} ?: paywall?.validate(currentColorScheme, resourceProvider)
?: fallbackPaywall(currentColorScheme, resourceProvider, error = PaywallValidationError.MissingPaywall)

val template = paywallData.validate().getOrElse {
internal fun PaywallData.validate(
currentColorScheme: ColorScheme,
resourceProvider: ResourceProvider,
): PaywallValidationResult.Legacy {
val template = validate().getOrElse {
return PaywallValidationResult.Legacy(
PaywallData.createDefaultForIdentifiers(
paywallData.config.packageIds,
config.packageIds,
currentColorScheme,
resourceProvider,
),
Expand All @@ -46,11 +55,72 @@ internal fun Offering.validatedLegacyPaywall(
)
}
return PaywallValidationResult.Legacy(
paywallData,
this,
template,
)
}

private fun Offering.fallbackPaywall(
currentColorScheme: ColorScheme,
resourceProvider: ResourceProvider,
error: PaywallValidationError,
): PaywallValidationResult.Legacy =
fallbackPaywall(
currentColorScheme = currentColorScheme,
resourceProvider = resourceProvider,
errors = nonEmptyListOf(error),
)

private fun Offering.fallbackPaywall(
currentColorScheme: ColorScheme,
resourceProvider: ResourceProvider,
errors: NonEmptyList<PaywallValidationError>,
): PaywallValidationResult.Legacy =
PaywallValidationResult.Legacy(
PaywallData.createDefault(
availablePackages,
currentColorScheme,
resourceProvider,
),
PaywallData.defaultTemplate,
errors,
)

@Suppress("MaxLineLength")
internal fun PaywallComponentsData.validate(): RcResult<PaywallValidationResult.Components, NonEmptyList<PaywallValidationError>> =
defaultLocalization
// Check that the default localization is present in the localizations map.
.errorIfNull(PaywallValidationError.AllLocalizationsMissing(defaultLocaleIdentifier))
.mapError { nonEmptyListOf(it) }
.map { defaultLocalization ->
// Build a NonEmptyMap, ensuring that we always have the default localization as fallback.
nonEmptyMapOf(defaultLocaleIdentifier to defaultLocalization, componentsLocalizations)
}
.flatMap { localizations ->
// We need to turn our NonEmptyMap<LocaleId, Map> into NonEmptyMap<LocaleId, NonEmptyMap>.
localizations.mapValues { (locale, map) ->
map.toNonEmptyMapOrNull()
.errorIfNull(PaywallValidationError.AllLocalizationsMissing(locale))
.mapError { nonEmptyListOf(it) }
}.mapValuesOrAccumulate { it }
}.flatMap { localizations ->
// Use the StyleFactory to recursively create and validate all ComponentStyles.
val styleFactory = StyleFactory(localizations)
val actionHandler: suspend (PaywallAction) -> Unit = { /* TODO Move the action handler to the UI layer. */ }
val config = componentsConfig.base
zipOrAccumulate(
styleFactory.create(config.stack, actionHandler),
config.stickyFooter?.let { styleFactory.create(it, actionHandler) }.orSuccessfullyNull(),
) { stack, stickyFooter ->
PaywallValidationResult.Components(
stack = stack,
stickyFooter = stickyFooter,
background = config.background,
locales = localizations.keys,
)
}
}

@Suppress("ReturnCount")
private fun PaywallData.validate(): Result<PaywallTemplate> {
val template = validateTemplate() ?: return Result.failure(PaywallValidationError.InvalidTemplate(templateName))
Expand Down Expand Up @@ -162,10 +232,15 @@ internal fun Offering.toLegacyPaywallState(
)
}

internal fun Offering.toComponentsPaywallState(validatedPaywallData: PaywallComponentsData): PaywallState =
internal fun Offering.toComponentsPaywallState(
validationResult: PaywallValidationResult.Components,
): PaywallState.Loaded.Components =
PaywallState.Loaded.Components(
stack = validationResult.stack,
stickyFooter = validationResult.stickyFooter,
background = validationResult.background,
offering = this,
data = validatedPaywallData,
locales = validationResult.locales,
)

/**
Expand Down Expand Up @@ -222,3 +297,6 @@ private fun PaywallData.LocalizedConfiguration.validateIcons(): PaywallValidatio
private fun PaywallData.validateTemplate(): PaywallTemplate? {
return PaywallTemplate.fromId(templateName)
}

private val PaywallComponentsData.defaultLocalization: Map<LocalizationKey, LocalizationData>?
get() = componentsLocalizations[defaultLocaleIdentifier]
Loading

0 comments on commit 178b07a

Please sign in to comment.