diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/HeaderComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/HeaderComponent.kt new file mode 100644 index 0000000000..9ebfec363b --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/HeaderComponent.kt @@ -0,0 +1,17 @@ +package com.revenuecat.purchases.paywalls.components + +import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.InternalRevenueCatAPI +import dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@InternalRevenueCatAPI +@Poko +@Serializable +@SerialName("header") +@Immutable +public class HeaderComponent( + @get:JvmSynthetic + public val stack: StackComponent, +) : PaywallComponent diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt index 39c0615c7e..37a7a82843 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt @@ -42,6 +42,7 @@ internal class PaywallComponentSerializer : KSerializer { "package" -> jsonDecoder.json.decodeFromString(json.toString()) "purchase_button" -> jsonDecoder.json.decodeFromString(json.toString()) "stack" -> jsonDecoder.json.decodeFromString(json.toString()) + "header" -> jsonDecoder.json.decodeFromString(json.toString()) "sticky_footer" -> jsonDecoder.json.decodeFromString(json.toString()) "text" -> jsonDecoder.json.decodeFromString(json.toString()) "icon" -> jsonDecoder.json.decodeFromString(json.toString()) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ComponentsConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ComponentsConfig.kt index 80d38c1851..d9dd50dffb 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ComponentsConfig.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ComponentsConfig.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases.paywalls.components.common import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import dev.drewhamilton.poko.Poko @@ -23,4 +24,6 @@ public class PaywallComponentsConfig( @get:JvmSynthetic @SerialName("sticky_footer") public val stickyFooter: StickyFooterComponent? = null, + @get:JvmSynthetic + public val header: HeaderComponent? = null, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt index 61e25f357a..523d5d0e11 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt @@ -4,6 +4,7 @@ import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.CarouselComponent import com.revenuecat.purchases.paywalls.components.CountdownComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.IconComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PackageComponent @@ -43,6 +44,7 @@ internal fun PaywallComponent.filter(predicate: (PaywallComponent) -> Boolean): is PurchaseButtonComponent -> queue.add(current.stack) is ButtonComponent -> queue.add(current.stack) is PackageComponent -> queue.add(current.stack) + is HeaderComponent -> queue.add(current.stack) is StickyFooterComponent -> queue.add(current.stack) is CarouselComponent -> queue.addAll(current.pages) is TabControlButtonComponent -> queue.add(current.stack) diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/HeaderComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/HeaderComponentTests.kt new file mode 100644 index 0000000000..496a7738a5 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/HeaderComponentTests.kt @@ -0,0 +1,129 @@ +package com.revenuecat.purchases.paywalls.components + +import com.revenuecat.purchases.ColorAlias +import com.revenuecat.purchases.JsonTools +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import org.intellij.lang.annotations.Language +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class HeaderComponentTests( + @Suppress("UNUSED_PARAMETER") name: String, + private val args: Args, +) { + + class Args( + @Language("json") + val json: String, + val expected: HeaderComponent, + ) + + companion object { + + @Suppress("LongMethod") + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun parameters(): Collection<*> = listOf( + arrayOf( + "non-empty stack", + Args( + json = """ + { + "type": "header", + "stack": { + "type": "stack", + "components": [ + { + "color": { + "light": { + "type": "alias", + "value": "primary" + } + }, + "components": [], + "id": "xmpgCrN9Rb", + "name": "Text", + "text_lid": "7bkohQjzIE", + "type": "text" + } + ] + } + } + """.trimIndent(), + expected = HeaderComponent( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("7bkohQjzIE"), + color = ColorScheme(light = ColorInfo.Alias(ColorAlias("primary"))) + ) + ), + ) + ) + ), + ), + arrayOf( + "empty stack", + Args( + json = """ + { + "type": "header", + "stack": { + "type": "stack", + "components": [] + } + } + """.trimIndent(), + expected = HeaderComponent( + stack = StackComponent( + components = emptyList(), + ) + ) + ), + ), + arrayOf( + "extra fields ignored", + Args( + json = """ + { + "type": "header", + "id": "header_1", + "name": "My Header", + "stack": { + "type": "stack", + "components": [] + } + } + """.trimIndent(), + expected = HeaderComponent( + stack = StackComponent( + components = emptyList(), + ) + ) + ), + ), + ) + } + + @Test + fun `Should properly deserialize HeaderComponent as HeaderComponent`() { + // Arrange, Act + val actual = JsonTools.json.decodeFromString(args.json) + + // Assert + assert(actual == args.expected) + } + + @Test + fun `Should properly deserialize HeaderComponent as PaywallComponent`() { + // Arrange, Act + val actual = JsonTools.json.decodeFromString(args.json) + + // Assert + assert(actual == args.expected) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ComponentsConfigTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ComponentsConfigTests.kt index 822fd96900..5610352052 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ComponentsConfigTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ComponentsConfigTests.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.components.common import com.revenuecat.purchases.ColorAlias import com.revenuecat.purchases.JsonTools +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import com.revenuecat.purchases.paywalls.components.TextComponent @@ -284,6 +285,190 @@ internal class ComponentsConfigTests { ) ), ), + arrayOf( + "header present", + Args( + json = """ + { + "stack": { + "type": "stack", + "components": [] + }, + "background": { + "type": "color", + "value": { + "light": { + "type": "alias", + "value": "primary" + } + } + }, + "header": { + "type": "header", + "stack": { + "type": "stack", + "components": [ + { + "color": { + "light": { + "type": "alias", + "value": "primary" + } + }, + "components": [], + "id": "xmpgCrN9Rb", + "name": "Text", + "text_lid": "7bkohQjzIE", + "type": "text" + } + ] + } + } + } + """.trimIndent(), + expected = PaywallComponentsConfig( + stack = StackComponent( + components = emptyList(), + ), + background = Background.Color( + value = ColorScheme( + light = ColorInfo.Alias(ColorAlias("primary")) + ) + ), + header = HeaderComponent( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("7bkohQjzIE"), + color = ColorScheme(light = ColorInfo.Alias(ColorAlias("primary"))) + ) + ), + ) + ) + ) + ), + ), + arrayOf( + "header absent", + Args( + json = """ + { + "stack": { + "type": "stack", + "components": [] + }, + "background": { + "type": "color", + "value": { + "light": { + "type": "alias", + "value": "primary" + } + } + } + } + """.trimIndent(), + expected = PaywallComponentsConfig( + stack = StackComponent( + components = emptyList(), + ), + background = Background.Color( + value = ColorScheme( + light = ColorInfo.Alias(ColorAlias("primary")) + ) + ) + ) + ), + ), + arrayOf( + "header null", + Args( + json = """ + { + "stack": { + "type": "stack", + "components": [] + }, + "background": { + "type": "color", + "value": { + "light": { + "type": "alias", + "value": "primary" + } + } + }, + "header": null + } + """.trimIndent(), + expected = PaywallComponentsConfig( + stack = StackComponent( + components = emptyList(), + ), + background = Background.Color( + value = ColorScheme( + light = ColorInfo.Alias(ColorAlias("primary")) + ) + ) + ) + ), + ), + arrayOf( + "header and sticky footer present", + Args( + json = """ + { + "stack": { + "type": "stack", + "components": [] + }, + "background": { + "type": "color", + "value": { + "light": { + "type": "alias", + "value": "primary" + } + } + }, + "header": { + "type": "header", + "stack": { + "type": "stack", + "components": [] + } + }, + "sticky_footer": { + "type": "sticky_footer", + "stack": { + "type": "stack", + "components": [] + } + } + } + """.trimIndent(), + expected = PaywallComponentsConfig( + stack = StackComponent( + components = emptyList(), + ), + background = Background.Color( + value = ColorScheme( + light = ColorInfo.Alias(ColorAlias("primary")) + ) + ), + header = HeaderComponent( + stack = StackComponent( + components = emptyList(), + ) + ), + stickyFooter = StickyFooterComponent( + stack = StackComponent( + components = emptyList(), + ) + ) + ) + ), + ), arrayOf( "unknown background type", Args( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt index 0978ba4c49..918c9fe092 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import com.revenuecat.purchases.ui.revenuecatui.components.button.ButtonComponentView import com.revenuecat.purchases.ui.revenuecatui.components.carousel.CarouselComponentView import com.revenuecat.purchases.ui.revenuecatui.components.countdown.CountdownComponentView +import com.revenuecat.purchases.ui.revenuecatui.components.header.HeaderComponentView import com.revenuecat.purchases.ui.revenuecatui.components.iconcomponent.IconComponentView import com.revenuecat.purchases.ui.revenuecatui.components.image.ImageComponentView import com.revenuecat.purchases.ui.revenuecatui.components.pkg.PackageComponentView @@ -16,6 +17,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponent import com.revenuecat.purchases.ui.revenuecatui.components.style.CarouselComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.CountdownComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.HeaderComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.IconComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.ImageComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.PackageComponentStyle @@ -68,6 +70,12 @@ internal fun ComponentView( ) } is ButtonComponentStyle -> ButtonComponentView(style = style, state = state, onClick = onClick, modifier = modifier) + is HeaderComponentStyle -> HeaderComponentView( + style = style, + state = state, + clickHandler = onClick, + modifier = modifier, + ) is StickyFooterComponentStyle -> StickyFooterComponentView( style = style, state = state, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt index f7e29398d1..3bfca30a21 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt @@ -3,16 +3,25 @@ package com.revenuecat.purchases.ui.revenuecatui.components import android.content.res.Configuration +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import com.revenuecat.purchases.Offering import com.revenuecat.purchases.paywalls.components.StackComponent @@ -73,24 +82,46 @@ internal fun LoadedPaywallComponents( state.update(localeList = configuration.locales) val style = state.stack + val headerComponentStyle = state.header val footerComponentStyle = state.stickyFooter val background = rememberBackgroundStyle(state.background) val onClick: suspend (PaywallAction) -> Unit = { action: PaywallAction -> handleClick(action, state, clickHandler) } + val density = LocalDensity.current + var headerHeightPx by remember { mutableIntStateOf(0) } + val headerHeightDp = remember(headerHeightPx, density) { with(density) { headerHeightPx.toDp() } } + SimpleBottomSheetScaffold( sheetState = state.sheet, modifier = modifier.background(background), ) { WithOptionalBackgroundOverlay(state, background = background) { Column { - ComponentView( - style = style, - state = state, - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()), - ) + Box(modifier = Modifier.weight(1f)) { + ComponentView( + style = style, + state = state, + onClick = onClick, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .conditional( + headerComponentStyle != null && !state.mainStackHasHeroImage, + ) { + padding(top = headerHeightDp) + }, + ) + headerComponentStyle?.let { headerStyle -> + ComponentView( + style = headerStyle, + state = state, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .onSizeChanged { headerHeightPx = it.height }, + ) + } + } footerComponentStyle?.let { ComponentView( style = it, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/header/HeaderComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/header/HeaderComponentView.kt new file mode 100644 index 0000000000..e0a20219ad --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/header/HeaderComponentView.kt @@ -0,0 +1,20 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.header + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView +import com.revenuecat.purchases.ui.revenuecatui.components.style.HeaderComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState + +@Composable +internal fun HeaderComponentView( + style: HeaderComponentStyle, + state: PaywallState.Loaded.Components, + clickHandler: suspend (PaywallAction) -> Unit, + modifier: Modifier = Modifier, +) { + StackComponentView(style.stackComponentStyle, state, clickHandler, modifier) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/HeaderComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/HeaderComponentStyle.kt new file mode 100644 index 0000000000..1cd8567aa7 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/HeaderComponentStyle.kt @@ -0,0 +1,13 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.style + +import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.paywalls.components.properties.Size + +@Immutable +internal data class HeaderComponentStyle( + @get:JvmSynthetic + val stackComponentStyle: StackComponentStyle, +) : ComponentStyle { + override val visible: Boolean = stackComponentStyle.visible + override val size: Size = stackComponentStyle.size +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 4ce24e5da7..3296c64f0a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.CarouselComponent import com.revenuecat.purchases.paywalls.components.CountdownComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.IconComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PackageComponent @@ -203,6 +204,7 @@ internal class StyleFactory( is ImageComponent -> { if (stillLookingForHeaderMedia) { ignoreTopWindowInsets = component.isHeaderImage + topWindowInsetsApplied = topWindowInsetsApplied || component.isHeaderImage } stillLookingForHeaderMedia = false } @@ -210,6 +212,7 @@ internal class StyleFactory( is VideoComponent -> { if (stillLookingForHeaderMedia) { ignoreTopWindowInsets = component.isHeaderVideo + topWindowInsetsApplied = topWindowInsetsApplied || component.isHeaderVideo } stillLookingForHeaderMedia = false } @@ -252,6 +255,13 @@ internal class StyleFactory( */ val ignoreTopWindowInsets by windowInsetsState::ignoreTopWindowInsets + /** + * Whether the tree contains a hero image (a full-width image/video as the first child of a ZLayer stack). + * Backed by [WindowInsetsState.topWindowInsetsApplied] — both are set during the same traversal step. + */ + val heroImageDetected: Boolean + get() = windowInsetsState.topWindowInsetsApplied + var defaultTabIndex: Int? = null val rcPackage: Package? get() = packageInfo?.pkg @@ -383,6 +393,11 @@ internal class StyleFactory( fun applyTopWindowInsetsIfNotYetApplied(to: ComponentStyle): ComponentStyle = when (to) { is StackComponentStyle -> to.copy(applyTopWindowInsets = !windowInsetsState.topWindowInsetsApplied) + is HeaderComponentStyle -> to.copy( + stackComponentStyle = to.stackComponentStyle.copy( + applyTopWindowInsets = !windowInsetsState.topWindowInsetsApplied, + ), + ) else -> to } @@ -413,6 +428,9 @@ internal class StyleFactory( is StickyFooterComponentStyle -> copy( stackComponentStyle = stackComponentStyle.copy(applyHorizontalWindowInsets = true), ) + is HeaderComponentStyle -> copy( + stackComponentStyle = stackComponentStyle.copy(applyHorizontalWindowInsets = true), + ) else -> this } as T @@ -435,9 +453,12 @@ internal class StyleFactory( val componentStyle: ComponentStyle, val availablePackages: AvailablePackages, val defaultTabIndex: Int?, + val heroImageDetected: Boolean = false, ) /** + * @param applyTopWindowInsets Whether to apply top window insets to the root of this tree (i.e. the + * passed-in [component]). Should be false when a header is rendered above this component. * @param applyBottomWindowInsets Whether to apply bottom window insets to the root of this tree (i.e. the * passed-in [component]). * @param applyHorizontalWindowInsets Whether to apply horizontal window insets to the root of this tree (i.e. the @@ -445,6 +466,7 @@ internal class StyleFactory( */ fun create( component: PaywallComponent, + applyTopWindowInsets: Boolean = true, applyBottomWindowInsets: Boolean = false, applyHorizontalWindowInsets: Boolean = false, ): Result> = @@ -456,7 +478,13 @@ internal class StyleFactory( nonEmptyListOf(PaywallValidationError.RootComponentUnsupportedProperties(component)), ) } - .map { componentStyle -> applyTopWindowInsetsIfNotYetApplied(to = componentStyle) } + .map { componentStyle -> + if (applyTopWindowInsets) { + applyTopWindowInsetsIfNotYetApplied(to = componentStyle) + } else { + componentStyle + } + } .map { componentStyle -> componentStyle.applyBottomWindowInsetsIfNecessary(applyBottomWindowInsets) } .map { componentStyle -> componentStyle.applyHorizontalWindowInsetsIfNecessary(applyHorizontalWindowInsets) @@ -466,6 +494,7 @@ internal class StyleFactory( componentStyle = componentStyle, availablePackages = packages, defaultTabIndex = defaultTabIndex, + heroImageDetected = heroImageDetected, ) } } @@ -481,6 +510,7 @@ internal class StyleFactory( is PackageComponent -> createPackageComponentStyle(component) is PurchaseButtonComponent -> createPurchaseButtonComponentStyle(component) is StackComponent -> createStackComponentStyle(component) + is HeaderComponent -> createHeaderComponentStyle(component) is StickyFooterComponent -> createStickyFooterComponentStyle(component) is TextComponent -> createTextComponentStyle(component) is IconComponent -> createIconComponentStyle(component) @@ -516,6 +546,16 @@ internal class StyleFactory( } } + private fun StyleFactoryScope.createHeaderComponentStyle( + component: HeaderComponent, + ): Result> = + // tabControlIndex is null because a header cannot be _inside_ a tab control. + withSelectedScope(packageInfo = null, tabControlIndex = null) { + createStackComponentStyle(component.stack).map { + HeaderComponentStyle(stackComponentStyle = it) + } + } + private fun StyleFactoryScope.createStickyFooterComponentStyle( component: StickyFooterComponent, ): Result> = diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 12aea758e6..6a8aecd6ea 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -89,8 +89,10 @@ internal sealed interface PaywallState { @Stable class Components( val stack: ComponentStyle, + val header: ComponentStyle?, val stickyFooter: ComponentStyle?, val background: BackgroundStyles, + val mainStackHasHeroImage: Boolean = false, /** * Some currencies do not commonly use decimals when displaying prices. Set this to false to accommodate * for that. diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index 55f2bf13cf..ab9556ffa0 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.CarouselComponent import com.revenuecat.purchases.paywalls.components.CountdownComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.IconComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PackageComponent @@ -189,22 +190,28 @@ internal fun Offering.validatePaywallComponentsDataOrNull( stripRules = stripRules, ) - // Combine the main stack with the stickyFooter and the background, or accumulate the encountered errors. + // Combine the main stack with the header, stickyFooter and the background, or accumulate the encountered errors. return zipOrAccumulate( first = styleFactory.create( config.stack, + applyTopWindowInsets = config.header == null, applyBottomWindowInsets = config.stickyFooter == null, applyHorizontalWindowInsets = true, ), - second = config.stickyFooter + second = config.header + ?.let { styleFactory.create(it, applyHorizontalWindowInsets = true) } + .orSuccessfullyNull(), + third = config.stickyFooter ?.let { styleFactory.create(it, applyBottomWindowInsets = true, applyHorizontalWindowInsets = true) } .orSuccessfullyNull(), - third = config.background.toBackgroundStyles(aliases = colorAliases), - ) { backendRootComponentResult, stickyFooterResult, background -> + fourth = config.background.toBackgroundStyles(aliases = colorAliases), + ) { backendRootComponentResult, headerResult, stickyFooterResult, background -> val hasAnyPackages = backendRootComponentResult.availablePackages.hasAnyPackages || + headerResult?.availablePackages?.hasAnyPackages ?: false || stickyFooterResult?.availablePackages?.hasAnyPackages ?: false val backendRootComponent = backendRootComponentResult.componentStyle + val header = headerResult?.componentStyle val stickyFooter = stickyFooterResult?.componentStyle // This is a temporary hack to make the root component fill the screen. This will be removed once we have a // definite solution for positioning the root component. @@ -215,14 +222,20 @@ internal fun Offering.validatePaywallComponentsDataOrNull( PaywallValidationResult.Components( stack = rootComponent, + header = header, stickyFooter = stickyFooter, background = background, locales = localizations.keys, zeroDecimalPlaceCountries = paywallComponents.data.zeroDecimalPlaceCountries.toSet(), variableConfig = paywallComponents.uiConfig.variableConfig, variableDataProvider = VariableDataProvider(resourceProvider), - packages = backendRootComponentResult.availablePackages.merge(with = stickyFooterResult?.availablePackages), - initialSelectedTabIndex = backendRootComponentResult.defaultTabIndex ?: stickyFooterResult?.defaultTabIndex, + packages = backendRootComponentResult.availablePackages + .merge(with = headerResult?.availablePackages) + .merge(with = stickyFooterResult?.availablePackages), + initialSelectedTabIndex = backendRootComponentResult.defaultTabIndex + ?: headerResult?.defaultTabIndex + ?: stickyFooterResult?.defaultTabIndex, + mainStackHasHeroImage = backendRootComponentResult.heroImageDetected, ) } } @@ -352,6 +365,7 @@ internal fun Offering.toComponentsPaywallState( return PaywallState.Loaded.Components( stack = validationResult.stack, + header = validationResult.header, stickyFooter = validationResult.stickyFooter, background = validationResult.background, showPricesWithDecimals = showPricesWithDecimals, @@ -365,6 +379,7 @@ internal fun Offering.toComponentsPaywallState( customVariables = customVariables, defaultCustomVariables = defaultCustomVariables, initialSelectedTabIndex = validationResult.initialSelectedTabIndex, + mainStackHasHeroImage = validationResult.mainStackHasHeroImage, purchases = purchases, ) } @@ -436,7 +451,9 @@ private val Offering.PaywallComponents.defaultVariableLocalization: Map stack.containsUnsupportedCondition() is PurchaseButtonComponent -> stack.containsUnsupportedCondition() + is HeaderComponent -> stack.containsUnsupportedCondition() is StickyFooterComponent -> stack.containsUnsupportedCondition() is CarouselComponent -> overrides.hasUnsupportedCondition() || pages.any { it.containsUnsupportedCondition() } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt index a3a255bd6d..e276869dee 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt @@ -37,6 +37,7 @@ internal sealed interface PaywallValidationResult { data class Components( val stack: ComponentStyle, + val header: ComponentStyle?, val stickyFooter: ComponentStyle?, val background: BackgroundStyles, /** @@ -51,6 +52,7 @@ internal sealed interface PaywallValidationResult { val variableDataProvider: VariableDataProvider, val packages: AvailablePackages, val initialSelectedTabIndex: Int?, + val mainStackHasHeroImage: Boolean = false, ) : PaywallValidationResult { // If a Components Paywall has an error, it will be reflected as a Legacy type so we can use the Legacy // fallback. diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt index 3fdcf77d86..84e3365a97 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt @@ -12,6 +12,7 @@ import com.revenuecat.purchases.Offering import com.revenuecat.purchases.UiConfig.AppConfig import com.revenuecat.purchases.UiConfig.AppConfig.FontsConfig import com.revenuecat.purchases.UiConfig.AppConfig.FontsConfig.FontInfo +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.StackComponent @@ -834,6 +835,125 @@ class PaywallComponentDataValidationTests { assertTrue(heroImage.ignoreTopWindowInsets) } + @Test + fun `Should set mainStackHasHeroImage when header and hero image coexist`() { + // Arrange + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + id = "paywall_id", + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + dimension = Dimension.Vertical(HorizontalAlignment.CENTER, START), + components = listOf( + StackComponent( + dimension = Dimension.ZLayer(TwoDimensionalAlignment.TOP), + components = listOf( + ImageComponent( + source = ThemeImageUrls( + light = ImageUrls( + original = URL("https://preview"), + webp = URL("https://preview"), + webpLowRes = URL("https://preview"), + width = 100u, + height = 100u, + ), + ) + ) + ) + ), + TestData.Components.monthlyPackageComponent, + ) + ), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + header = HeaderComponent(stack = StackComponent(components = emptyList())), + ), + ), + componentsLocalizations = mapOf( + defaultLocale to mapOf(LocalizationKey("key1") to LocalizationData.Text("value1")), + ), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + assertTrue(validated is PaywallValidationResult.Components) + assertNull(validated.errors) + val result = validated as PaywallValidationResult.Components + assertTrue(result.mainStackHasHeroImage) + assertNotNull(result.header) + // Top window insets go to the hero image parent, not the root stack + val actualStack = result.stack as StackComponentStyle + val heroParent = actualStack.children[0] as StackComponentStyle + assertTrue(heroParent.applyTopWindowInsets) + } + + @Test + fun `Should set mainStackHasHeroImage when header and direct hero image coexist`() { + // Arrange - image directly in the root Vertical stack, not wrapped in a ZLayer + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + id = "paywall_id", + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + dimension = Dimension.Vertical(HorizontalAlignment.CENTER, START), + components = listOf( + ImageComponent( + source = ThemeImageUrls( + light = ImageUrls( + original = URL("https://preview"), + webp = URL("https://preview"), + webpLowRes = URL("https://preview"), + width = 100u, + height = 100u, + ), + ) + ), + TestData.Components.monthlyPackageComponent, + ) + ), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + header = HeaderComponent(stack = StackComponent(components = emptyList())), + ), + ), + componentsLocalizations = mapOf( + defaultLocale to mapOf(LocalizationKey("key1") to LocalizationData.Text("value1")), + ), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + assertTrue(validated is PaywallValidationResult.Components) + assertNull(validated.errors) + val result = validated as PaywallValidationResult.Components + assertTrue(result.mainStackHasHeroImage) + assertNotNull(result.header) + } + @Test fun `Should apply top window insets to the root if there is no hero image`() { // Arrange diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt index 0a6aa595a8..8b451bf7e3 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt @@ -29,6 +29,7 @@ import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.PaywallComponent import com.revenuecat.purchases.paywalls.components.PurchaseButtonComponent import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import com.revenuecat.purchases.paywalls.components.TabControlButtonComponent import com.revenuecat.purchases.paywalls.components.TabControlComponent @@ -1270,6 +1271,7 @@ class TabsComponentViewTests { is PurchaseButtonComponent -> queue.add(current.stack) is ButtonComponent -> queue.add(current.stack) is PackageComponent -> queue.add(current.stack) + is HeaderComponent -> queue.add(current.stack) is StickyFooterComponent -> queue.add(current.stack) is CarouselComponent -> queue.addAll(current.pages) is TabControlButtonComponent -> queue.add(current.stack) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt index b4adfaf8af..4f64f84336 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt @@ -352,6 +352,7 @@ internal class PaywallStateLoadedComponentsLocaleTests( deviceLocales: NonEmptyList, ) = PaywallState.Loaded.Components( stack = previewStackComponentStyle(children = emptyList()), + header = null, stickyFooter = null, background = BackgroundStyles.Color(color = ColorStyles(light = ColorStyle.Solid(Color.White))), showPricesWithDecimals = true, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsPackageSelectionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsPackageSelectionTests.kt index 6b5501de62..62e4c55f72 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsPackageSelectionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsPackageSelectionTests.kt @@ -163,6 +163,7 @@ internal class PaywallStateLoadedComponentsPackageSelectionTests { initialSelectedTabIndex: Int?, ) = PaywallState.Loaded.Components( stack = previewStackComponentStyle(children = emptyList()), + header = null, stickyFooter = null, background = BackgroundStyles.Color(color = ColorStyles(light = ColorStyle.Solid(Color.White))), showPricesWithDecimals = true, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt index d5b7a93dfb..7b184bd43c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.paywalls.components.PartialImageComponent import com.revenuecat.purchases.paywalls.components.PartialStackComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.PurchaseButtonComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import com.revenuecat.purchases.paywalls.components.TabControlButtonComponent @@ -72,10 +73,12 @@ internal class ContainsUnsupportedConditionTests { private fun config( stack: StackComponent, + header: HeaderComponent? = null, stickyFooter: StickyFooterComponent? = null, ) = PaywallComponentsConfig( stack = stack, background = Background.Color(color), + header = header, stickyFooter = stickyFooter, ) @@ -114,6 +117,18 @@ internal class ContainsUnsupportedConditionTests { assertTrue(config(stack = emptyStack(), stickyFooter = footer).containsUnsupportedCondition()) } + @Test + fun `Config detects unsupported in header`() { + val header = HeaderComponent( + stack = emptyStack( + components = listOf( + textComponent(overrides = listOf(unsupportedOverride)), + ), + ), + ) + assertTrue(config(stack = emptyStack(), header = header).containsUnsupportedCondition()) + } + // endregion // region TextComponent @@ -324,6 +339,23 @@ internal class ContainsUnsupportedConditionTests { // endregion + // region HeaderComponent + + @Test + fun `HeaderComponent detects unsupported in its stack`() { + val header = HeaderComponent( + stack = emptyStack( + components = listOf( + textComponent(overrides = listOf(unsupportedOverride)), + ), + ), + ) + val stack = emptyStack(components = listOf(header)) + assertTrue(stack.containsUnsupportedCondition()) + } + + // endregion + // region CarouselComponent @Test diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt index 3d98b26b8b..a765390287 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.PaywallComponent import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.HeaderComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -58,6 +59,7 @@ internal fun FakePaywallState( ), defaultLocaleIdentifier: LocaleId = LocaleId("en_US"), customVariables: Map = emptyMap(), + header: HeaderComponent? = null, stickyFooter: StickyFooterComponent? = null, ): PaywallState.Loaded.Components { val packageComponents = packages.map { pkg -> @@ -75,6 +77,7 @@ internal fun FakePaywallState( base = PaywallComponentsConfig( stack = StackComponent(components = components + packageComponents), background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + header = header, stickyFooter = stickyFooter, ), ),