From 8a0c1d0e4a0fecb65b309d70b7c19c040ce59cd6 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:23:25 +0100 Subject: [PATCH 01/20] Adds an optional previewImageLoader parameter. --- .../components/image/ImageComponentView.kt | 228 +++++++++++++++--- .../revenuecatui/composables/RemoteImage.kt | 69 +++++- 2 files changed, 251 insertions(+), 46 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt index a1c7aa87c5..4f1ac94c36 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -1,8 +1,17 @@ @file:JvmSynthetic +@file:Suppress("TooManyFunctions") package com.revenuecat.purchases.ui.revenuecatui.components.image +import android.graphics.Bitmap +import android.graphics.Bitmap.Config +import android.graphics.Canvas import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.Px import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -12,9 +21,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.core.graphics.component1 +import androidx.core.graphics.component2 +import androidx.core.graphics.component3 +import androidx.core.graphics.component4 +import coil.ImageLoader +import coil.decode.DataSource +import coil.request.SuccessResult import com.revenuecat.purchases.Offering import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background @@ -30,9 +48,12 @@ import com.revenuecat.purchases.paywalls.components.properties.FitMode import com.revenuecat.purchases.paywalls.components.properties.ImageUrls import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toContentScale +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.urlsForCurrentTheme import com.revenuecat.purchases.ui.revenuecatui.components.modifier.overlay import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size import com.revenuecat.purchases.ui.revenuecatui.components.properties.rememberColorStyle @@ -54,6 +75,7 @@ internal fun ImageComponentView( state: PaywallState.Loaded.Components, modifier: Modifier = Modifier, selected: Boolean = false, + previewImageLoader: ImageLoader? = null, ) { // Get an ImageComponentState that calculates the overridden properties we should use. val imageState = rememberUpdatedImageComponentState( @@ -72,46 +94,92 @@ internal fun ImageComponentView( .applyIfNotNull(imageState.shape) { clip(it) }, placeholderUrlString = imageState.imageUrls.webpLowRes.toString(), contentScale = imageState.contentScale, - imagePreview = R.drawable.android, + previewImageLoader = previewImageLoader, ) } } -@Preview -@Composable -private fun ImageComponentView_Preview_Default() { - Box(modifier = Modifier.background(ComposeColor.Red)) { - ImageComponentView( - style = previewImageComponentStyle(), - state = previewEmptyState(), - ) - } -} +private class PreviewParameters( + @Px val imageWidth: UInt, + @Px val imageHeight: UInt, + val viewSize: Size, + val fitMode: FitMode, +) -@Preview -@Composable -private fun ImageComponentView_Preview_FixedWidthFitHeight() { - Box(modifier = Modifier.background(ComposeColor.Red)) { - ImageComponentView( - style = previewImageComponentStyle( - size = Size(width = SizeConstraint.Fixed(72u), height = SizeConstraint.Fit), - contentScale = FitMode.FILL.toContentScale(), - ), - state = previewEmptyState(), - ) - } +private class PreviewParametersProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(200u), height = Fixed(200u)), + fitMode = FitMode.FILL, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(200u), height = Fixed(200u)), + fitMode = FitMode.FIT, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(200u), height = Fixed(50u)), + fitMode = FitMode.FILL, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(200u), height = Fixed(50u)), + fitMode = FitMode.FIT, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(50u), height = Fixed(200u)), + fitMode = FitMode.FILL, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(50u), height = Fixed(200u)), + fitMode = FitMode.FIT, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fixed(72u), height = Fit), + fitMode = FitMode.FILL, + ), + PreviewParameters( + imageWidth = 100u, + imageHeight = 100u, + viewSize = Size(width = Fit, height = Fixed(72u)), + fitMode = FitMode.FILL, + ), + PreviewParameters( + imageWidth = 1909u, + imageHeight = 1306u, + viewSize = Size(width = SizeConstraint.Fill, height = Fit), + fitMode = FitMode.FIT, + ), + ) } @Preview @Composable -private fun ImageComponentView_Preview_FitWidthFixedHeight() { +private fun ImageComponentView_Preview( + @PreviewParameter(PreviewParametersProvider::class) parameters: PreviewParameters, +) { + val themeImageUrls = previewThemeImageUrls(widthPx = parameters.imageWidth, heightPx = parameters.imageHeight) Box(modifier = Modifier.background(ComposeColor.Red)) { ImageComponentView( style = previewImageComponentStyle( - size = Size(width = SizeConstraint.Fit, height = SizeConstraint.Fixed(72u)), - contentScale = FitMode.FILL.toContentScale(), + themeImageUrls = themeImageUrls, + size = parameters.viewSize, + fitMode = parameters.fitMode, ), state = previewEmptyState(), + previewImageLoader = previewImageLoader(themeImageUrls), ) } } @@ -119,10 +187,16 @@ private fun ImageComponentView_Preview_FitWidthFixedHeight() { @Preview @Composable private fun ImageComponentView_Preview_SmallerContainer() { - Box(modifier = Modifier.height(200.dp).background(ComposeColor.Red)) { + val themeImageUrls = previewThemeImageUrls(widthPx = 400u, heightPx = 400u) + Box(modifier = Modifier.height(200.dp).background(ComposeColor.Blue)) { ImageComponentView( - style = previewImageComponentStyle(), + style = previewImageComponentStyle( + themeImageUrls = themeImageUrls, + size = Size(width = Fixed(400u), height = Fixed(400u)), + fitMode = FitMode.FIT, + ), state = previewEmptyState(), + previewImageLoader = previewImageLoader(themeImageUrls), ) } } @@ -131,9 +205,13 @@ private fun ImageComponentView_Preview_SmallerContainer() { @Preview @Composable private fun ImageComponentView_Preview_LinearGradient() { + val themeImageUrls = previewThemeImageUrls(widthPx = 100u, heightPx = 100u) Box(modifier = Modifier.background(ComposeColor.Red)) { ImageComponentView( style = previewImageComponentStyle( + themeImageUrls = themeImageUrls, + size = Size(width = Fixed(400u), height = Fit), + fitMode = FitMode.FIT, overlay = ColorScheme( light = ColorInfo.Gradient.Linear( degrees = -90f, @@ -163,9 +241,13 @@ private fun ImageComponentView_Preview_LinearGradient() { @Preview @Composable private fun ImageComponentView_Preview_RadialGradient() { + val themeImageUrls = previewThemeImageUrls(widthPx = 100u, heightPx = 100u) Box(modifier = Modifier.background(ComposeColor.Red)) { ImageComponentView( style = previewImageComponentStyle( + themeImageUrls = themeImageUrls, + size = Size(width = Fixed(400u), height = Fit), + fitMode = FitMode.FIT, overlay = ColorScheme( light = ColorInfo.Gradient.Radial( listOf( @@ -193,17 +275,16 @@ private fun ImageComponentView_Preview_RadialGradient() { @Suppress("LongParameterList") @Composable private fun previewImageComponentStyle( - url: URL = URL("https://sample-videos.com/img/Sample-jpg-image-5mb.jpg"), - lowResURL: URL = URL("https://assets.pawwalls.com/954459_1701163461.jpg"), - size: Size = Size(width = SizeConstraint.Fixed(400u), height = SizeConstraint.Fit), - contentScale: ContentScale = ContentScale.Fit, + themeImageUrls: ThemeImageUrls, + size: Size, + fitMode: FitMode, overlay: ColorScheme? = null, ) = ImageComponentStyle( - sources = nonEmptyMapOf(LocaleId("en_US") to ThemeImageUrls(light = ImageUrls(url, url, lowResURL, 1000u, 1000u))), + sources = nonEmptyMapOf(LocaleId("en_US") to themeImageUrls), size = size, shape = RoundedCornerShape(20.dp, 20.dp, 20.dp, 20.dp), overlay = overlay, - contentScale = contentScale, + contentScale = fitMode.toContentScale(), overrides = null, ) @@ -236,3 +317,80 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! return offering.toComponentsPaywallState(validated) } + +@Composable +private fun previewImageLoader(themeImageUrls: ThemeImageUrls) = + previewImageLoader(imageUrls = themeImageUrls.urlsForCurrentTheme) + +@Composable +private fun previewImageLoader( + imageUrls: ImageUrls, + @DrawableRes resource: Int = R.drawable.android, +): ImageLoader { + val context = LocalContext.current + return ImageLoader.Builder(context) + .components { + add { chain -> + SuccessResult( + drawable = BitmapDrawable( + chain.request.context.resources, + context.getDrawable(resource)!!.toBitmap( + width = imageUrls.width, + height = imageUrls.height, + // Create a deterministic color from the URL and size. + background = with(imageUrls) { "$original:$width$height".toRgbColor() }, + ), + ), + request = chain.request, + dataSource = DataSource.MEMORY, + ) + } + } + .build() +} + +private fun previewThemeImageUrls(widthPx: UInt, heightPx: UInt): ThemeImageUrls = + ThemeImageUrls( + light = ImageUrls( + original = URL("https://preview"), + webp = URL("https://preview"), + webpLowRes = URL("https://preview"), + width = widthPx, + height = heightPx, + ), + ) + +/** + * Converts this drawable to a bitmap with a [background]. + */ +@Suppress("DestructuringDeclarationWithTooManyEntries") +fun Drawable.toBitmap( + @Px width: UInt, + @Px height: UInt, + @ColorInt background: Int, +): Bitmap { + val (oldLeft, oldTop, oldRight, oldBottom) = bounds + + val bitmap = Bitmap.createBitmap(width.toInt(), height.toInt(), Config.ARGB_8888) + val canvas = Canvas(bitmap) + + canvas.drawColor(background) + + setBounds(0, 0, width.toInt(), height.toInt()) + draw(canvas) + setBounds(oldLeft, oldTop, oldRight, oldBottom) + + return bitmap +} + +@Suppress("MagicNumber") +private fun String.toRgbColor(): Int { + val hash = hashCode() + // Use the hash to generate ARGB color components + val r = (hash shr 16 and 0xFF) + val g = (hash shr 8 and 0xFF) + val b = (hash and 0xFF) + + // Combine the components into a color integer with full opacity (alpha = 255) + return 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt index 7bd8c95b19..556b02442c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases.ui.revenuecatui.composables import android.content.Context +import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -11,6 +12,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.withSave import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -21,12 +28,16 @@ import coil.compose.rememberAsyncImagePainter import coil.disk.DiskCache import coil.memory.MemoryCache import coil.request.CachePolicy +import coil.request.ErrorResult import coil.request.ImageRequest +import coil.request.SuccessResult import coil.transform.Transformation import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.UIConstant import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger import com.revenuecat.purchases.ui.revenuecatui.helpers.isInPreviewMode +import kotlinx.coroutines.runBlocking +import kotlin.math.roundToInt @SuppressWarnings("LongParameterList") @Composable @@ -37,7 +48,6 @@ internal fun LocalImage( contentDescription: String? = null, transformation: Transformation? = null, alpha: Float = 1f, - @DrawableRes imagePreview: Int? = null, ) { Image( source = ImageSource.Local(resource), @@ -47,7 +57,7 @@ internal fun LocalImage( contentDescription = contentDescription, transformation = transformation, alpha = alpha, - imagePreview = imagePreview, + previewImageLoader = null, ) } @@ -65,7 +75,7 @@ internal fun RemoteImage( contentDescription: String? = null, transformation: Transformation? = null, alpha: Float = 1f, - @DrawableRes imagePreview: Int? = null, + previewImageLoader: ImageLoader? = null, ) { Image( source = ImageSource.Remote(urlString), @@ -75,7 +85,7 @@ internal fun RemoteImage( contentDescription = contentDescription, transformation = transformation, alpha = alpha, - imagePreview = imagePreview, + previewImageLoader = previewImageLoader, ) } @@ -100,16 +110,17 @@ private fun Image( contentDescription: String?, transformation: Transformation?, alpha: Float, - @DrawableRes imagePreview: Int?, + previewImageLoader: ImageLoader?, ) { // Previews don't support images - if (isInPreviewMode() && imagePreview == null) { + val isInPreviewMode = isInPreviewMode() + if (isInPreviewMode && previewImageLoader == null) { return ImageForPreviews(modifier) } var useCache by remember { mutableStateOf(true) } val applicationContext = LocalContext.current.applicationContext - val imageLoader = remember(useCache) { + val imageLoader = previewImageLoader.takeIf { isInPreviewMode } ?: remember(useCache) { applicationContext.getRevenueCatUIImageLoader(readCache = useCache) } @@ -129,7 +140,6 @@ private fun Image( modifier = modifier, contentScale = contentScale, alpha = alpha, - imagePreview = imagePreview, onError = { Logger.w("Image failed to load. Will try again disabling cache") useCache = false @@ -145,7 +155,6 @@ private fun Image( modifier = modifier, contentScale = contentScale, alpha = alpha, - imagePreview = imagePreview, ) } } @@ -161,7 +170,6 @@ private fun AsyncImage( contentScale: ContentScale, contentDescription: String?, alpha: Float, - @DrawableRes imagePreview: Int? = null, onError: ((AsyncImagePainter.State.Error) -> Unit)? = null, ) { AsyncImage( @@ -170,7 +178,14 @@ private fun AsyncImage( placeholder = placeholderSource?.let { rememberAsyncImagePainter( model = it.data, - placeholder = if (isInPreviewMode() && imagePreview != null) painterResource(imagePreview) else null, + placeholder = if (isInPreviewMode()) { + when (val result = runBlocking { imageLoader.execute(imageRequest) }) { + is SuccessResult -> DrawablePainter(result.drawable) + is ErrorResult -> throw result.throwable + } + } else { + null + }, imageLoader = imageLoader, contentScale = contentScale, onError = { errorState -> @@ -229,3 +244,35 @@ private fun Context.getRevenueCatUIImageLoader(readCache: Boolean): ImageLoader .memoryCachePolicy(cachePolicy) .build() } + +/** + * This is loosely based on [Accompanist's Drawable Painter](https://google.github.io/accompanist/drawablepainter/). + * This is not production-quality code and should only be used for Previews. If we ever have a need for this, it's + * better to use the Accompanist Drawable Painter library directly. + */ +private class DrawablePainter( + private val drawable: Drawable, +) : Painter() { + + override fun DrawScope.onDraw() { + drawIntoCanvas { canvas -> + // Update the Drawable's bounds + drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt()) + + canvas.withSave { + drawable.draw(canvas.nativeCanvas) + } + } + } + + override val intrinsicSize: Size = drawable.intrinsicSize + + private val Drawable.intrinsicSize: Size + get() = when { + // Only return a finite size if the drawable has an intrinsic size + intrinsicWidth >= 0 && intrinsicHeight >= 0 -> { + Size(width = intrinsicWidth.toFloat(), height = intrinsicHeight.toFloat()) + } + else -> Size.Unspecified + } +} From d26ee925603c8b556a875dc3f1e62d3a0b13110d Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:34:38 +0100 Subject: [PATCH 02/20] Adds and uses aspect ratio to aid layout when one axis is Fit. --- .../components/image/ImageComponentState.kt | 75 +++++++++++++------ .../components/image/ImageComponentView.kt | 18 ++++- .../components/modifier/AspectRatio.kt | 26 +++++++ 3 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/AspectRatio.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt index f0612350aa..ccd87ed322 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt @@ -30,6 +30,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.buildPresentedPartial import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toContentScale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toLocaleId import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toShape +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.AspectRatio import com.revenuecat.purchases.ui.revenuecatui.components.style.ImageComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState @@ -117,11 +118,48 @@ internal class ImageComponentState( if (darkMode) themeImageUrls.dark ?: themeImageUrls.light else themeImageUrls.light } + private val imageAspectRatio: Float by derivedStateOf { + imageUrls.width.toFloat() / imageUrls.height.toFloat() + } + @get:JvmSynthetic val size: Size by derivedStateOf { (presentedPartial?.partial?.size ?: style.size).adjustForImage(imageUrls, density) } + /** + * Depending on the [size], it's sometimes possible to figure out the aspect ratio of the view before the + * measurement phase. If that is the case, this will be non-null. This is especially helpful when one axis is set + * to Fit. + */ + @get:JvmSynthetic + val aspectRatio: AspectRatio? by derivedStateOf { + with(size) { + when (val height = height) { + is Fit -> when (width) { + is Fit -> AspectRatio(ratio = imageAspectRatio, matchHeightConstraintsFirst = true) + is Fill -> AspectRatio(ratio = imageAspectRatio, matchHeightConstraintsFirst = true) + is Fixed -> null + } + + is Fill -> when (width) { + is Fit -> AspectRatio(ratio = imageAspectRatio, matchHeightConstraintsFirst = false) + is Fill -> null + is Fixed -> null + } + + is Fixed -> when (val width = width) { + is Fit -> null + is Fill -> null + is Fixed -> AspectRatio( + ratio = width.value.toFloat() / height.value.toFloat(), + matchHeightConstraintsFirst = true, + ) + } + } + } + } + @get:JvmSynthetic val shape: Shape? by derivedStateOf { presentedPartial?.partial?.maskShape?.toShape() ?: style.shape } @@ -149,25 +187,23 @@ internal class ImageComponentState( } /** - * We don't want to have Fit in any dimension, as that resolves to zero, which results in an invisible image. So - * instead, we use the px size from the provided [ImageUrls], converted to dp. + * Adjusts this size to take into account the size of the image. */ private fun Size.adjustForImage(imageUrls: ImageUrls, density: Density): Size = Size( width = when (width) { is Fit -> { - // If height is Fixed, we'll have to scale width by the same factor. - val scaleFactor = when (val height = height) { - is Fit, - is Fill, - -> 1f + when (val height = height) { + is Fit -> Fixed(with(density) { imageUrls.width.toInt().toDp().value.toUInt() }) + is Fill -> width is Fixed -> { + // If height is Fixed, we'll have to scale width by the same factor. val imageHeightDp = with(density) { imageUrls.height.toInt().toDp() } - height.value.toFloat() / imageHeightDp.value + val scaleFactor = height.value.toFloat() / imageHeightDp.value + Fixed(with(density) { (scaleFactor * imageUrls.width.toInt()).toDp().value.toUInt() }) } } - Fixed(with(density) { (scaleFactor * imageUrls.width.toInt()).toDp().value.toUInt() }) } is Fill, @@ -175,20 +211,15 @@ internal class ImageComponentState( -> width }, height = when (height) { - is Fit -> { - // If width is Fixed, we'll have to scale height by the same factor. - val scaleFactor = when (val width = width) { - is Fit, - is Fill, - -> 1f - - is Fixed -> { - val imageWidthDp = with(density) { imageUrls.width.toInt().toDp() } - width.value.toFloat() / imageWidthDp.value - } + is Fit -> when (val width = width) { + is Fit -> Fixed(with(density) { imageUrls.height.toInt().toDp().value.toUInt() }) + is Fill -> height + is Fixed -> { + // If width is Fixed, we'll have to scale height by the same factor. + val imageWidthDp = with(density) { imageUrls.width.toInt().toDp() } + val scaleFactor = width.value.toFloat() / imageWidthDp.value + Fixed(with(density) { (scaleFactor * imageUrls.height.toInt()).toDp().value.toUInt() }) } - - Fixed(with(density) { (scaleFactor * imageUrls.height.toInt()).toDp().value.toUInt() }) } is Fill, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt index 4f1ac94c36..67b95e26a2 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -47,13 +47,14 @@ import com.revenuecat.purchases.paywalls.components.properties.ColorScheme import com.revenuecat.purchases.paywalls.components.properties.FitMode import com.revenuecat.purchases.paywalls.components.properties.ImageUrls import com.revenuecat.purchases.paywalls.components.properties.Size -import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toContentScale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.urlsForCurrentTheme +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.aspectRatio import com.revenuecat.purchases.ui.revenuecatui.components.modifier.overlay import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size import com.revenuecat.purchases.ui.revenuecatui.components.properties.rememberColorStyle @@ -90,6 +91,7 @@ internal fun ImageComponentView( urlString = imageState.imageUrls.webp.toString(), modifier = modifier .size(imageState.size) + .applyIfNotNull(imageState.aspectRatio) { aspectRatio(it) } .applyIfNotNull(overlay) { overlay(it, imageState.shape ?: RectangleShape) } .applyIfNotNull(imageState.shape) { clip(it) }, placeholderUrlString = imageState.imageUrls.webpLowRes.toString(), @@ -159,7 +161,13 @@ private class PreviewParametersProvider : PreviewParameterProvider Date: Mon, 6 Jan 2025 16:46:09 +0100 Subject: [PATCH 03/20] Fixes cyclomatic complexity. --- .../components/image/ImageComponentState.kt | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt index ccd87ed322..629595e962 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentState.kt @@ -20,6 +20,7 @@ import androidx.window.core.layout.WindowWidthSizeClass import com.revenuecat.purchases.paywalls.components.properties.ColorScheme import com.revenuecat.purchases.paywalls.components.properties.ImageUrls import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed @@ -191,40 +192,45 @@ internal class ImageComponentState( */ private fun Size.adjustForImage(imageUrls: ImageUrls, density: Density): Size = Size( - width = when (width) { - is Fit -> { - when (val height = height) { - is Fit -> Fixed(with(density) { imageUrls.width.toInt().toDp().value.toUInt() }) - is Fill -> width - - is Fixed -> { - // If height is Fixed, we'll have to scale width by the same factor. - val imageHeightDp = with(density) { imageUrls.height.toInt().toDp() } - val scaleFactor = height.value.toFloat() / imageHeightDp.value - Fixed(with(density) { (scaleFactor * imageUrls.width.toInt()).toDp().value.toUInt() }) - } - } - } + width = width.adjustDimension( + other = height, + thisImageDimensionPx = imageUrls.width, + otherImageDimensionPx = imageUrls.height, + density = density, + ), + height = height.adjustDimension( + other = width, + thisImageDimensionPx = imageUrls.height, + otherImageDimensionPx = imageUrls.width, + density = density, + ), + ) - is Fill, - is Fixed, - -> width - }, - height = when (height) { - is Fit -> when (val width = width) { - is Fit -> Fixed(with(density) { imageUrls.height.toInt().toDp().value.toUInt() }) - is Fill -> height - is Fixed -> { - // If width is Fixed, we'll have to scale height by the same factor. - val imageWidthDp = with(density) { imageUrls.width.toInt().toDp() } - val scaleFactor = width.value.toFloat() / imageWidthDp.value - Fixed(with(density) { (scaleFactor * imageUrls.height.toInt()).toDp().value.toUInt() }) - } + /** + * Adjusts this size constraint to take into account the size of the image. + */ + private fun SizeConstraint.adjustDimension( + other: SizeConstraint, + thisImageDimensionPx: UInt, + otherImageDimensionPx: UInt, + density: Density, + ): SizeConstraint = when (this) { + is Fit -> { + when (other) { + is Fit -> Fixed(with(density) { thisImageDimensionPx.toInt().toDp().value.toUInt() }) + is Fill -> this + + is Fixed -> { + // If the other dimension is Fixed, we'll have to scale this one by the same factor. + val otherImageDimensionDp = with(density) { otherImageDimensionPx.toInt().toDp() } + val scaleFactor = other.value.toFloat() / otherImageDimensionDp.value + Fixed(with(density) { (scaleFactor * thisImageDimensionPx.toInt()).toDp().value.toUInt() }) } + } + } - is Fill, - is Fixed, - -> height - }, - ) + is Fill, + is Fixed, + -> this + } } From 313af7e26bf79bbf6bdd7447ab3431f8774e7484 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:48:04 +0100 Subject: [PATCH 04/20] Fixes the gradient previews. --- .../ui/revenuecatui/components/image/ImageComponentView.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt index 67b95e26a2..dd3444bcdb 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -217,7 +217,7 @@ private fun ImageComponentView_Preview_SmallerContainer() { @Preview @Composable private fun ImageComponentView_Preview_LinearGradient() { - val themeImageUrls = previewThemeImageUrls(widthPx = 100u, heightPx = 100u) + val themeImageUrls = previewThemeImageUrls(widthPx = 400u, heightPx = 400u) Box(modifier = Modifier.background(ComposeColor.Red)) { ImageComponentView( style = previewImageComponentStyle( @@ -245,6 +245,7 @@ private fun ImageComponentView_Preview_LinearGradient() { ), ), state = previewEmptyState(), + previewImageLoader = previewImageLoader(themeImageUrls), ) } } @@ -253,7 +254,7 @@ private fun ImageComponentView_Preview_LinearGradient() { @Preview @Composable private fun ImageComponentView_Preview_RadialGradient() { - val themeImageUrls = previewThemeImageUrls(widthPx = 100u, heightPx = 100u) + val themeImageUrls = previewThemeImageUrls(widthPx = 400u, heightPx = 400u) Box(modifier = Modifier.background(ComposeColor.Red)) { ImageComponentView( style = previewImageComponentStyle( @@ -280,6 +281,7 @@ private fun ImageComponentView_Preview_RadialGradient() { ), ), state = previewEmptyState(), + previewImageLoader = previewImageLoader(themeImageUrls), ) } } From a6959c2f879cce305c7de00fab875b6b7b0f3591 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:49:57 +0100 Subject: [PATCH 05/20] Marks DrawablePainter as experimental. --- .../revenuecatui/composables/RemoteImage.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt index 556b02442c..693ef9bc23 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt @@ -32,6 +32,7 @@ import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.SuccessResult import coil.transform.Transformation +import com.revenuecat.purchases.ui.revenuecatui.ExperimentalPreviewRevenueCatUIPurchasesAPI import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.UIConstant import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger @@ -178,14 +179,7 @@ private fun AsyncImage( placeholder = placeholderSource?.let { rememberAsyncImagePainter( model = it.data, - placeholder = if (isInPreviewMode()) { - when (val result = runBlocking { imageLoader.execute(imageRequest) }) { - is SuccessResult -> DrawablePainter(result.drawable) - is ErrorResult -> throw result.throwable - } - } else { - null - }, + placeholder = if (isInPreviewMode()) imageLoader.getPreviewPlaceholder(imageRequest) else null, imageLoader = imageLoader, contentScale = contentScale, onError = { errorState -> @@ -216,6 +210,13 @@ private fun ImageForPreviews(modifier: Modifier) { ) } +@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class) +private fun ImageLoader.getPreviewPlaceholder(imageRequest: ImageRequest): Painter = + when (val result = runBlocking { execute(imageRequest) }) { + is SuccessResult -> DrawablePainter(result.drawable) + is ErrorResult -> throw result.throwable + } + // Note: these values have to match those in CoilImageDownloader private const val MAX_CACHE_SIZE_BYTES = 25 * 1024 * 1024L // 25 MB private const val PAYWALL_IMAGE_CACHE_FOLDER = "revenuecatui_cache" @@ -249,7 +250,11 @@ private fun Context.getRevenueCatUIImageLoader(readCache: Boolean): ImageLoader * This is loosely based on [Accompanist's Drawable Painter](https://google.github.io/accompanist/drawablepainter/). * This is not production-quality code and should only be used for Previews. If we ever have a need for this, it's * better to use the Accompanist Drawable Painter library directly. + * + * It's annotated with [ExperimentalPreviewRevenueCatUIPurchasesAPI] to discourage usage in production and as a nudge + * to read this documentation. */ +@ExperimentalPreviewRevenueCatUIPurchasesAPI private class DrawablePainter( private val drawable: Drawable, ) : Painter() { From a4d626be10428807a9a9065688cc1a0226e39255 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:03:23 +0100 Subject: [PATCH 06/20] Handles gradient percentages in the 0..100 range. --- .../com/revenuecat/paywallstester/SamplePaywalls.kt | 4 ++-- .../paywalls/components/properties/ColorInfo.kt | 3 +++ .../components/LoadedPaywallComponents.kt | 4 ++-- .../components/image/ImageComponentView.kt | 8 ++++---- .../ui/revenuecatui/components/modifier/Border.kt | 12 ++++++------ .../ui/revenuecatui/components/modifier/Shadow.kt | 6 +++--- .../components/properties/BackgroundStyle.kt | 8 ++++---- .../revenuecatui/components/properties/ColorStyle.kt | 7 ++++++- .../components/text/TextComponentView.kt | 12 ++++++------ 9 files changed, 36 insertions(+), 28 deletions(-) diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt index d4eabf3bfe..31874516e7 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt @@ -760,11 +760,11 @@ object SamplePaywalls { ColorInfo.Gradient.Point( color = Color(red = 0xFF, green = 0xFF, blue = 0xFF, alpha = 0xFF) .toArgb(), - percent = 0.4f, + percent = 40f, ), ColorInfo.Gradient.Point( color = Color(red = 5, green = 124, blue = 91).toArgb(), - percent = 1f, + percent = 100f, ), ), ), diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/ColorInfo.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/ColorInfo.kt index cf9f03835a..a0610f055c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/ColorInfo.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/ColorInfo.kt @@ -57,6 +57,9 @@ sealed interface ColorInfo { @Serializable(with = RgbaStringArgbColorIntDeserializer::class) @ColorInt @get:JvmSynthetic val color: Int, + /** + * A percentage value in the range 0.0..100.0. + */ @get:JvmSynthetic val percent: Float, ) } 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 d6d2b11bec..caeed86cd6 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 @@ -199,11 +199,11 @@ private fun LoadedPaywallComponents_Preview_Bless() { ColorInfo.Gradient.Point( color = Color(red = 0xFF, green = 0xFF, blue = 0xFF, alpha = 0xFF) .toArgb(), - percent = 0.4f, + percent = 40f, ), ColorInfo.Gradient.Point( color = Color(red = 5, green = 124, blue = 91).toArgb(), - percent = 1f, + percent = 100f, ), ), ), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt index dd3444bcdb..72827bb023 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -234,11 +234,11 @@ private fun ImageComponentView_Preview_LinearGradient() { ), ColorInfo.Gradient.Point( color = Color.parseColor("#8800FF00"), - percent = 0.5f, + percent = 50f, ), ColorInfo.Gradient.Point( color = Color.parseColor("#880000FF"), - percent = 1f, + percent = 100f, ), ), ), @@ -270,11 +270,11 @@ private fun ImageComponentView_Preview_RadialGradient() { ), ColorInfo.Gradient.Point( color = Color.parseColor("#8800FF00"), - percent = 0.5f, + percent = 50f, ), ColorInfo.Gradient.Point( color = Color.parseColor("#880000FF"), - percent = 1f, + percent = 100f, ), ), ), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Border.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Border.kt index 95ea42bab0..1847cb7b00 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Border.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Border.kt @@ -127,15 +127,15 @@ private fun Border_Preview_LinearGradient(shape: Shape) { points = listOf( ColorInfo.Gradient.Point( color = Color.Cyan.toArgb(), - percent = 0.1f, + percent = 10f, ), ColorInfo.Gradient.Point( color = Color(red = 0x00, green = 0x66, blue = 0xff).toArgb(), - percent = 0.3f, + percent = 30f, ), ColorInfo.Gradient.Point( color = Color(red = 0xA0, green = 0x00, blue = 0xA0).toArgb(), - percent = 0.8f, + percent = 80f, ), ), ), @@ -163,15 +163,15 @@ private fun Border_Preview_RadialGradient(shape: Shape) { points = listOf( ColorInfo.Gradient.Point( color = Color.Cyan.toArgb(), - percent = 0.8f, + percent = 80f, ), ColorInfo.Gradient.Point( color = Color(red = 0x00, green = 0x66, blue = 0xff).toArgb(), - percent = 0.9f, + percent = 90f, ), ColorInfo.Gradient.Point( color = Color(red = 0xA0, green = 0x00, blue = 0xA0).toArgb(), - percent = 0.96f, + percent = 96f, ), ), ), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Shadow.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Shadow.kt index 714dd4adbd..ab76ee429b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Shadow.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Shadow.kt @@ -157,15 +157,15 @@ private fun Shadow_Preview_Gradient_CustomShape() { points = listOf( ColorInfo.Gradient.Point( color = Color.Red.toArgb(), - percent = 0.1f, + percent = 10f, ), ColorInfo.Gradient.Point( color = Color.Green.toArgb(), - percent = 0.5f, + percent = 50f, ), ColorInfo.Gradient.Point( color = Color.Blue.toArgb(), - percent = 0.9f, + percent = 90f, ), ), ), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/BackgroundStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/BackgroundStyle.kt index 7d74ca2db7..9b22395f72 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/BackgroundStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/BackgroundStyle.kt @@ -93,11 +93,11 @@ private fun Background_Preview_ColorGradientLinear() { ), ColorInfo.Gradient.Point( color = Color.Green.toArgb(), - percent = 0.5f, + percent = 50f, ), ColorInfo.Gradient.Point( color = Color.Blue.toArgb(), - percent = 1f, + percent = 100f, ), ), ), @@ -124,11 +124,11 @@ private fun Background_Preview_ColorGradientRadial() { ), ColorInfo.Gradient.Point( color = Color.Green.toArgb(), - percent = 0.5f, + percent = 50f, ), ColorInfo.Gradient.Point( color = Color.Blue.toArgb(), - percent = 1f, + percent = 100f, ), ), ), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/ColorStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/ColorStyle.kt index e5bd5866c1..c8a3109446 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/ColorStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/ColorStyle.kt @@ -27,6 +27,11 @@ import kotlin.math.cos import kotlin.math.max import kotlin.math.sin +/** + * Used to normalize [ColorInfo.Gradient.Point.percent] values (which are in the range 0..100) to a range of 0..1. + */ +private const val PERCENT_SCALE = 100f + /** * Ready to use color properties for the current theme. */ @@ -71,7 +76,7 @@ internal fun ColorInfo.toColorStyle(): ColorStyle { } private fun List.toColorStops(): Array> = - map { point -> point.percent to Color(point.color) } + map { point -> point.percent / PERCENT_SCALE to Color(point.color) } .toTypedArray() @Stable diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index 2c3bb5a73a..d9e061bc13 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -325,15 +325,15 @@ private fun TextComponentView_Preview_LinearGradient() { points = listOf( ColorInfo.Gradient.Point( color = Color.Cyan.toArgb(), - percent = 0.1f, + percent = 10f, ), ColorInfo.Gradient.Point( color = Color(red = 0x00, green = 0x66, blue = 0xff).toArgb(), - percent = 0.3f, + percent = 30f, ), ColorInfo.Gradient.Point( color = Color(red = 0xA0, green = 0x00, blue = 0xA0).toArgb(), - percent = 0.8f, + percent = 80f, ), ), ), @@ -362,15 +362,15 @@ private fun TextComponentView_Preview_RadialGradient() { points = listOf( ColorInfo.Gradient.Point( color = Color.Cyan.toArgb(), - percent = 0.1f, + percent = 10f, ), ColorInfo.Gradient.Point( color = Color(red = 0x00, green = 0x66, blue = 0xff).toArgb(), - percent = 0.8f, + percent = 80f, ), ColorInfo.Gradient.Point( color = Color(red = 0xA0, green = 0x00, blue = 0xA0).toArgb(), - percent = 1f, + percent = 100f, ), ), ), From 1adb3e8d8abeef0b7244d9482d9d594daa3be3a5 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:15:02 +0100 Subject: [PATCH 07/20] Splits restore logic to handleRestorePurchases and calls it from handleAction. --- .../ui/revenuecatui/data/PaywallViewModel.kt | 140 +++++++++--------- 1 file changed, 74 insertions(+), 66 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 1fa753208e..eb6015fde8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -166,76 +166,12 @@ internal class PaywallViewModelImpl( } } - @Suppress("NestedBlockDepth", "CyclomaticComplexMethod", "LongMethod") override fun restorePurchases() { if (verifyNoActionInProgressOrStartAction()) { return } viewModelScope.launch { - try { - val customRestoreHandler = purchaseLogic?.let { it::performRestore } - - when (purchases.purchasesAreCompletedBy) { - PurchasesAreCompletedBy.MY_APP -> { - checkNotNull(customRestoreHandler) { - "myAppPurchaseLogic must not be null when purchases.purchasesAreCompletedBy " + - "is PurchasesAreCompletedBy.MY_APP" - } - val customerInfo = purchases.awaitCustomerInfo() - when (val result = customRestoreHandler(customerInfo)) { - is PurchaseLogicResult.Success -> { - purchases.syncPurchases() - - shouldDisplayBlock?.let { - if (!it(customerInfo)) { - Logger.d( - "Dismissing paywall after restore since display " + - "condition has not been met", - ) - options.dismissRequest() - } - } - } - is PurchaseLogicResult.Cancellation -> { - // silently ignore - } - is PurchaseLogicResult.Error -> { - result.errorDetails?.let { _actionError.value = it } - } - } - } - - PurchasesAreCompletedBy.REVENUECAT -> { - listener?.onRestoreStarted() - if (customRestoreHandler != null) { - Logger.w( - "myAppPurchaseLogic expected be null when " + - "purchases.purchasesAreCompletedBy is .REVENUECAT.\n" + - "myAppPurchaseLogic.performRestore will not be executed.", - ) - } - val customerInfo = purchases.awaitRestore() - Logger.i("Restore purchases successful: $customerInfo") - listener?.onRestoreCompleted(customerInfo) - - shouldDisplayBlock?.let { - if (!it(customerInfo)) { - Logger.d("Dismissing paywall after restore since display condition has not been met") - options.dismissRequest() - } - } - } - - else -> { - Logger.e("Unsupported purchase completion type: ${purchases.purchasesAreCompletedBy}") - } - } - } catch (e: PurchasesException) { - Logger.e("Error restoring purchases: $e") - listener?.onRestoreError(e.error) - _actionError.value = e.error - } - + handleRestorePurchases() finishAction() } } @@ -252,12 +188,84 @@ internal class PaywallViewModelImpl( } override suspend fun handleAction(action: PaywallAction, activity: Activity?) { + if (verifyNoActionInProgressOrStartAction()) { + return + } when (action) { - is PaywallAction.RestorePurchases -> TODO() + is PaywallAction.RestorePurchases -> handleRestorePurchases() is PaywallAction.PurchasePackage -> TODO() is PaywallAction.NavigateBack -> TODO() is PaywallAction.NavigateTo -> TODO() } + finishAction() + } + + @Suppress("NestedBlockDepth", "CyclomaticComplexMethod", "LongMethod") + private suspend fun handleRestorePurchases() { + try { + val customRestoreHandler: (suspend (CustomerInfo) -> PurchaseLogicResult)? = + purchaseLogic?.let { it::performRestore } + + when (purchases.purchasesAreCompletedBy) { + PurchasesAreCompletedBy.MY_APP -> { + checkNotNull(customRestoreHandler) { + "myAppPurchaseLogic must not be null when purchases.purchasesAreCompletedBy " + + "is PurchasesAreCompletedBy.MY_APP" + } + val customerInfo = purchases.awaitCustomerInfo() + when (val result = customRestoreHandler(customerInfo)) { + is PurchaseLogicResult.Success -> { + purchases.syncPurchases() + + shouldDisplayBlock?.let { + if (!it(customerInfo)) { + Logger.d( + "Dismissing paywall after restore since display " + + "condition has not been met", + ) + options.dismissRequest() + } + } + } + is PurchaseLogicResult.Cancellation -> { + // silently ignore + } + is PurchaseLogicResult.Error -> { + result.errorDetails?.let { _actionError.value = it } + } + } + } + + PurchasesAreCompletedBy.REVENUECAT -> { + listener?.onRestoreStarted() + if (customRestoreHandler != null) { + Logger.w( + "myAppPurchaseLogic expected be null when " + + "purchases.purchasesAreCompletedBy is .REVENUECAT.\n" + + "myAppPurchaseLogic.performRestore will not be executed.", + ) + } + val customerInfo = purchases.awaitRestore() + Logger.i("Restore purchases successful: $customerInfo") + listener?.onRestoreCompleted(customerInfo) + + shouldDisplayBlock?.let { + if (!it(customerInfo)) { + Logger.d("Dismissing paywall after restore since display condition has not been met") + options.dismissRequest() + } + } + } + + else -> { + Logger.e("Unsupported purchase completion type: ${purchases.purchasesAreCompletedBy}") + } + } + } catch (e: PurchasesException) { + Logger.e("Error restoring purchases: $e") + listener?.onRestoreError(e.error) + _actionError.value = e.error + } } private suspend fun handlePackagePurchase(activity: Activity) { From 734faad5053daec6fe055d67deb497cbeb6eaf8e Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:18:29 +0100 Subject: [PATCH 08/20] Calls handlePackagePurchase from handleAction. --- .../purchases/ui/revenuecatui/data/PaywallViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index eb6015fde8..f126747c1f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -193,7 +193,13 @@ internal class PaywallViewModelImpl( } when (action) { is PaywallAction.RestorePurchases -> handleRestorePurchases() - is PaywallAction.PurchasePackage -> TODO() + is PaywallAction.PurchasePackage -> + if (activity == null) { + Logger.e("Activity is null, not initiating package purchase") + } else { + handlePackagePurchase(activity) + } + is PaywallAction.NavigateBack -> TODO() is PaywallAction.NavigateTo -> TODO() } From be64ae67cac62150bc05f32ae91a69c5b0f850ef Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:25:56 +0100 Subject: [PATCH 09/20] Calls closePaywall from handleAction. --- .../purchases/ui/revenuecatui/data/PaywallViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index f126747c1f..26285e2c1b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -200,7 +200,7 @@ internal class PaywallViewModelImpl( handlePackagePurchase(activity) } - is PaywallAction.NavigateBack -> TODO() + is PaywallAction.NavigateBack -> closePaywall() is PaywallAction.NavigateTo -> TODO() } finishAction() From 09be9d43462b99f631be29c80a462314f59f4b18 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 17:02:42 +0100 Subject: [PATCH 10/20] Moves click handling to the Composable for easier navigation handling. --- .../ui/revenuecatui/InternalPaywall.kt | 55 ++++++++++++++++++- .../ui/revenuecatui/LoadingPaywall.kt | 11 ++-- .../ui/revenuecatui/data/PaywallViewModel.kt | 41 ++++---------- .../ui/revenuecatui/data/testdata/TestData.kt | 33 ++++++++--- .../components/PaywallActionTests.kt | 24 ++------ 5 files changed, 101 insertions(+), 63 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index a1889446d9..010d0569b8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -1,9 +1,11 @@ package com.revenuecat.purchases.ui.revenuecatui import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper import android.content.res.Configuration +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -32,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.viewmodel.compose.viewModel import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.ui.revenuecatui.UIConstant.defaultAnimation import com.revenuecat.purchases.ui.revenuecatui.components.LoadedPaywallComponents import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction @@ -44,6 +47,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.currentColors import com.revenuecat.purchases.ui.revenuecatui.data.isInFullScreenMode import com.revenuecat.purchases.ui.revenuecatui.data.processed.PaywallTemplate import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional +import com.revenuecat.purchases.ui.revenuecatui.extensions.openUriOrElse import com.revenuecat.purchases.ui.revenuecatui.fonts.PaywallTheme import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalActivity import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger @@ -256,10 +260,57 @@ private fun ErrorDialog( @Composable private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend (PaywallAction) -> Unit { val activity = LocalActivity.current - return remember(viewModel, activity) { + val context = activity ?: LocalContext.current + return remember(viewModel) { { action -> - viewModel.handleAction(action, activity) + when (action) { + is PaywallAction.RestorePurchases -> viewModel.handleRestorePurchases() + is PaywallAction.PurchasePackage -> + if (activity == null) { + Logger.e("Activity is null, not initiating package purchase") + } else { + viewModel.handlePackagePurchase(activity) + } + + is PaywallAction.NavigateBack -> viewModel.closePaywall() + is PaywallAction.NavigateTo -> when (val destination = action.destination) { + is ButtonComponent.Destination.CustomerCenter -> + Logger.w("Opening Customer Center is not yet implemented.") + + is ButtonComponent.Destination.PrivacyPolicy -> context.handleUrlDestination( + url = destination.urlLid, + method = destination.method, + ) + + is ButtonComponent.Destination.Terms -> context.handleUrlDestination( + url = destination.urlLid, + method = destination.method, + ) + + is ButtonComponent.Destination.Url -> context.handleUrlDestination( + url = destination.urlLid, + method = destination.method, + ) + } + } + } + } +} + +private fun Context.handleUrlDestination(url: String, method: ButtonComponent.UrlMethod) { + when (method) { + ButtonComponent.UrlMethod.IN_APP_BROWSER -> TODO() + ButtonComponent.UrlMethod.EXTERNAL_BROWSER, + ButtonComponent.UrlMethod.DEEP_LINK, + -> openUriOrElse(url) { exception -> + val message = if (exception is ActivityNotFoundException) { + getString(R.string.no_browser_cannot_open_link) + } else { + getString(R.string.cannot_open_link) + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + Logger.w(message) } } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt index 49c7c83106..2bd3c739ec 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt @@ -20,7 +20,6 @@ import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.TestStoreProduct import com.revenuecat.purchases.paywalls.PaywallData -import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.composables.CloseButton import com.revenuecat.purchases.ui.revenuecatui.composables.DisableTouchesComposable import com.revenuecat.purchases.ui.revenuecatui.composables.Fade @@ -201,15 +200,19 @@ private class LoadingViewModel( // no-op } - override fun restorePurchases() { + override suspend fun handlePackagePurchase(activity: Activity) { // no-op } - override fun clearActionError() = Unit + override fun restorePurchases() { + // no-op + } - override suspend fun handleAction(action: PaywallAction, activity: Activity?) { + override suspend fun handleRestorePurchases() { // no-op } + + override fun clearActionError() = Unit } @Preview(showBackground = true) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 26285e2c1b..9c52581bd9 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -24,7 +24,6 @@ import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult -import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger @@ -59,12 +58,12 @@ internal interface PaywallViewModel { * Note: This method requires the context to be an activity or to allow reaching an activity */ fun purchaseSelectedPackage(activity: Activity?) + suspend fun handlePackagePurchase(activity: Activity) fun restorePurchases() + suspend fun handleRestorePurchases() fun clearActionError() - - suspend fun handleAction(action: PaywallAction, activity: Activity?) } @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @@ -157,22 +156,14 @@ internal class PaywallViewModelImpl( Logger.e("Activity is null, not initiating package purchase") return } - if (verifyNoActionInProgressOrStartAction()) { - return - } viewModelScope.launch { handlePackagePurchase(activity) - finishAction() } } override fun restorePurchases() { - if (verifyNoActionInProgressOrStartAction()) { - return - } viewModelScope.launch { handleRestorePurchases() - finishAction() } } @@ -187,27 +178,11 @@ internal class PaywallViewModelImpl( } } - override suspend fun handleAction(action: PaywallAction, activity: Activity?) { + @Suppress("NestedBlockDepth", "CyclomaticComplexMethod", "LongMethod") + override suspend fun handleRestorePurchases() { if (verifyNoActionInProgressOrStartAction()) { return } - when (action) { - is PaywallAction.RestorePurchases -> handleRestorePurchases() - is PaywallAction.PurchasePackage -> - if (activity == null) { - Logger.e("Activity is null, not initiating package purchase") - } else { - handlePackagePurchase(activity) - } - - is PaywallAction.NavigateBack -> closePaywall() - is PaywallAction.NavigateTo -> TODO() - } - finishAction() - } - - @Suppress("NestedBlockDepth", "CyclomaticComplexMethod", "LongMethod") - private suspend fun handleRestorePurchases() { try { val customRestoreHandler: (suspend (CustomerInfo) -> PurchaseLogicResult)? = purchaseLogic?.let { it::performRestore } @@ -272,9 +247,14 @@ internal class PaywallViewModelImpl( listener?.onRestoreError(e.error) _actionError.value = e.error } + + finishAction() } - private suspend fun handlePackagePurchase(activity: Activity) { + override suspend fun handlePackagePurchase(activity: Activity) { + if (verifyNoActionInProgressOrStartAction()) { + return + } when (val currentState = _state.value) { is PaywallState.Loaded.Legacy -> { val selectedPackage = currentState.selectedPackage.value @@ -288,6 +268,7 @@ internal class PaywallViewModelImpl( Logger.e("Unexpected state trying to purchase package: $currentState") } } + finishAction() } @Suppress("LongMethod", "NestedBlockDepth") diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt index bcade04c49..8703715856 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt @@ -17,7 +17,6 @@ import com.revenuecat.purchases.models.TestStoreProduct import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.R -import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewModel import com.revenuecat.purchases.ui.revenuecatui.data.loadedLegacy @@ -511,6 +510,20 @@ internal class MockViewModel( } } + var handlePackagePurchaseCount = 0 + private set + var handlePackagePurchaseParams = mutableListOf() + private set + override suspend fun handlePackagePurchase(activity: Activity) { + handlePackagePurchaseCount++ + handlePackagePurchaseParams.add(activity) + if (allowsPurchases) { + simulateActionInProgress() + } else { + unsupportedMethod("Can't purchase mock view model") + } + } + var restorePurchasesCallCount = 0 private set override fun restorePurchases() { @@ -522,6 +535,17 @@ internal class MockViewModel( } } + var handleRestorePurchasesCallCount = 0 + private set + override suspend fun handleRestorePurchases() { + handleRestorePurchasesCallCount++ + if (allowsPurchases) { + simulateActionInProgress() + } else { + unsupportedMethod("Can't restore purchases") + } + } + var clearActionErrorCallCount = 0 private set override fun clearActionError() { @@ -529,13 +553,6 @@ internal class MockViewModel( _actionError.value = null } - private val _clickActions = mutableListOf() - val clickActions: List - get() = _clickActions - override suspend fun handleAction(action: PaywallAction, activity: Activity?) { - _clickActions.add(action) - } - private fun simulateActionInProgress() { viewModelScope.launch { awaitSimulateActionInProgress() diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt index c28b4beaae..8440587fcc 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt @@ -30,6 +30,7 @@ import com.revenuecat.purchases.ui.revenuecatui.InternalPaywall import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockViewModel import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,24 +43,21 @@ class PaywallActionTests { val composeTestRule = createComposeRule() @Test - fun `Should pass the PaywallAction to the ViewModel`(): Unit = with(composeTestRule) { + fun `Should pass the right PaywallActions to the ViewModel`(): Unit = with(composeTestRule) { // Arrange val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb())) val defaultLocale = LocaleId("en_US") val localizationKeyRestore = LocalizationKey("restore") val localizationKeyBack = LocalizationKey("back") val localizationKeyPurchase = LocalizationKey("purchase") - val localizationKeyNavigate = LocalizationKey("navigate") val localizationDataRestore = LocalizationData.Text("restore") val localizationDataBack = LocalizationData.Text("back") val localizationDataPurchase = LocalizationData.Text("purchase") - val localizationDataNavigate = LocalizationData.Text("navigate") val localizations = nonEmptyMapOf( defaultLocale to nonEmptyMapOf( localizationKeyRestore to localizationDataRestore, localizationKeyBack to localizationDataBack, localizationKeyPurchase to localizationDataPurchase, - localizationKeyNavigate to localizationDataNavigate, ) ) // Bit of a convoluted way to create components, to ensure we use an an exhaustive when, forcing ourselves to @@ -68,7 +66,6 @@ class PaywallActionTests { PaywallAction.RestorePurchases to localizationKeyRestore, PaywallAction.NavigateBack to localizationKeyBack, PaywallAction.PurchasePackage to localizationKeyPurchase, - PaywallAction.NavigateTo(ButtonComponent.Destination.CustomerCenter) to localizationKeyNavigate, ).map { (action, key) -> when (action) { is PaywallAction.RestorePurchases, @@ -96,22 +93,11 @@ class PaywallActionTests { clickButtonsWithText(localizationDataRestore, expectedCount = 2) clickButtonsWithText(localizationDataBack, expectedCount = 2) clickButtonsWithText(localizationDataPurchase, expectedCount = 2) - clickButtonsWithText(localizationDataNavigate, expectedCount = 2) // Assert - viewModel.clickActions.forEachIndexed { index, action -> - when (index) { - 0 -> assert(action is PaywallAction.RestorePurchases) - 1 -> assert(action is PaywallAction.RestorePurchases) - 2 -> assert(action is PaywallAction.NavigateBack) - 3 -> assert(action is PaywallAction.NavigateBack) - 4 -> assert(action is PaywallAction.PurchasePackage) - 5 -> assert(action is PaywallAction.PurchasePackage) - 6 -> assert(action is PaywallAction.NavigateTo) - 7 -> assert(action is PaywallAction.NavigateTo) - else -> error("Unexpected PaywallAction at index $index: $action") - } - } + assertEquals(2, viewModel.handleRestorePurchasesCallCount) + assertEquals(2, viewModel.closePaywallCallCount) + assertEquals(2, viewModel.handlePackagePurchaseCount) } private fun SemanticsNodeInteractionsProvider.clickButtonsWithText( From 6e9a0c531107b6589fe2c8309b0bc4367b63b318 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:03:28 +0100 Subject: [PATCH 11/20] Fixes PaywallActionTests. --- .../revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt | 4 ++-- .../purchases/ui/revenuecatui/data/testdata/TestData.kt | 1 - .../ui/revenuecatui/components/PaywallActionTests.kt | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index 010d0569b8..0dbd0bb24f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -259,8 +259,8 @@ private fun ErrorDialog( @Composable private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend (PaywallAction) -> Unit { - val activity = LocalActivity.current - val context = activity ?: LocalContext.current + val context: Context = LocalContext.current + val activity: Activity? = context.getActivity() return remember(viewModel) { { action -> diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt index 8703715856..a16797540a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt @@ -493,7 +493,6 @@ internal class MockViewModel( private set override fun closePaywall() { closePaywallCallCount++ - unsupportedMethod() } var purchaseSelectedPackageCallCount = 0 diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt index 8440587fcc..330ec8b918 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt @@ -43,7 +43,7 @@ class PaywallActionTests { val composeTestRule = createComposeRule() @Test - fun `Should pass the right PaywallActions to the ViewModel`(): Unit = with(composeTestRule) { + fun `Should pass the PaywallAction to the ViewModel`(): Unit = with(composeTestRule) { // Arrange val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb())) val defaultLocale = LocaleId("en_US") @@ -85,7 +85,7 @@ class PaywallActionTests { val options = PaywallOptions.Builder(dismissRequest = { }) .setOffering(offering) .build() - val viewModel = MockViewModel(offering = offering) + val viewModel = MockViewModel(offering = offering, allowsPurchases = true) // Act setContent { InternalPaywall(options, viewModel) } @@ -120,7 +120,7 @@ class PaywallActionTests { when (this) { is PaywallAction.NavigateBack -> ButtonComponent.Action.NavigateBack is PaywallAction.NavigateTo -> ButtonComponent.Action.NavigateTo(destination) - is PaywallAction.RestorePurchases -> ButtonComponent.Action.NavigateBack + is PaywallAction.RestorePurchases -> ButtonComponent.Action.RestorePurchases is PaywallAction.PurchasePackage -> error( "PurchasePackage is not a ButtonComponent.Action. It is handled by PurchaseButtonComponent instead." ) From f57414ec803d4877c7a645c886298c004a98421f Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:25:53 +0100 Subject: [PATCH 12/20] Handles URL button destinations. --- .../paywalls/components/ButtonComponent.kt | 15 +++- .../ui/revenuecatui/InternalPaywall.kt | 19 +--- .../revenuecatui/components/PaywallAction.kt | 10 ++- .../components/button/ButtonComponentState.kt | 76 ++++++++++++++++ .../components/button/ButtonComponentView.kt | 10 ++- .../components/style/ButtonComponentStyle.kt | 25 +++++- .../components/style/StyleFactory.kt | 46 +++++++--- .../components/PaywallActionTests.kt | 13 ++- .../button/ButtonComponentViewTests.kt | 90 ++++++++++++++++++- .../components/style/StyleFactoryTests.kt | 42 +++++++++ 10 files changed, 309 insertions(+), 37 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentState.kt diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt index 1cfb4b07cc..a95ec93830 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt @@ -4,6 +4,7 @@ import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.paywalls.components.ButtonComponent.Action import com.revenuecat.purchases.paywalls.components.ButtonComponent.Destination import com.revenuecat.purchases.paywalls.components.ButtonComponent.UrlMethod +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey import dev.drewhamilton.poko.Poko import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName @@ -46,15 +47,21 @@ class ButtonComponent( @Serializable data class PrivacyPolicy( - @get:JvmSynthetic val urlLid: String, + @get:JvmSynthetic val urlLid: LocalizationKey, @get:JvmSynthetic val method: UrlMethod, ) : Destination @Serializable - data class Terms(@get:JvmSynthetic val urlLid: String, @get:JvmSynthetic val method: UrlMethod) : Destination + data class Terms( + @get:JvmSynthetic val urlLid: LocalizationKey, + @get:JvmSynthetic val method: UrlMethod, + ) : Destination @Serializable - data class Url(@get:JvmSynthetic val urlLid: String, @get:JvmSynthetic val method: UrlMethod) : Destination + data class Url( + @get:JvmSynthetic val urlLid: LocalizationKey, + @get:JvmSynthetic val method: UrlMethod, + ) : Destination } @InternalRevenueCatAPI @@ -203,4 +210,4 @@ private enum class DestinationSurrogate { @OptIn(InternalRevenueCatAPI::class) @Suppress("ConstructorParameterNaming", "PropertyName") @Serializable -private class UrlSurrogate(val url_lid: String, val method: UrlMethod) +private class UrlSurrogate(val url_lid: LocalizationKey, val method: UrlMethod) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index 0dbd0bb24f..9b6f4df6d3 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -275,21 +275,10 @@ private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend ( is PaywallAction.NavigateBack -> viewModel.closePaywall() is PaywallAction.NavigateTo -> when (val destination = action.destination) { - is ButtonComponent.Destination.CustomerCenter -> - Logger.w("Opening Customer Center is not yet implemented.") - - is ButtonComponent.Destination.PrivacyPolicy -> context.handleUrlDestination( - url = destination.urlLid, - method = destination.method, - ) - - is ButtonComponent.Destination.Terms -> context.handleUrlDestination( - url = destination.urlLid, - method = destination.method, - ) - - is ButtonComponent.Destination.Url -> context.handleUrlDestination( - url = destination.urlLid, + is PaywallAction.NavigateTo.Destination.CustomerCenter -> + Logger.w("Customer Center is not yet implemented on Android.") + is PaywallAction.NavigateTo.Destination.Url -> context.handleUrlDestination( + url = destination.url, method = destination.method, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallAction.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallAction.kt index cbb28a6a66..aa72e9dba6 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallAction.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallAction.kt @@ -8,5 +8,13 @@ internal sealed interface PaywallAction { object NavigateBack : PaywallAction object PurchasePackage : PaywallAction - @Poko class NavigateTo(@get:JvmSynthetic val destination: ButtonComponent.Destination) : PaywallAction + @Poko class NavigateTo(@get:JvmSynthetic val destination: Destination) : PaywallAction { + sealed interface Destination { + object CustomerCenter : Destination + data class Url( + @get:JvmSynthetic val url: String, + @get:JvmSynthetic val method: ButtonComponent.UrlMethod, + ) : Destination + } + } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentState.kt new file mode 100644 index 0000000000..fb3c037a0b --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentState.kt @@ -0,0 +1,76 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.button + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.text.intl.Locale +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toLocaleId +import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState + +@JvmSynthetic +@Composable +internal fun rememberButtonComponentState( + style: ButtonComponentStyle, + paywallState: PaywallState.Loaded.Components, +): ButtonComponentState = + rememberButtonComponentState( + style = style, + localeProvider = { paywallState.locale }, + ) + +@JvmSynthetic +@Composable +internal fun rememberButtonComponentState( + style: ButtonComponentStyle, + localeProvider: () -> Locale, +): ButtonComponentState = remember(style) { + ButtonComponentState( + style = style, + localeProvider = localeProvider, + ) +} + +@Stable +internal class ButtonComponentState( + private val style: ButtonComponentStyle, + private val localeProvider: () -> Locale, +) { + + @get:JvmSynthetic + val action by derivedStateOf { + val localeId = localeProvider().toLocaleId() + + style.action.toPaywallAction(localeId) + } + + private fun ButtonComponentStyle.Action.toPaywallAction(localeId: LocaleId): PaywallAction = + when (this) { + is ButtonComponentStyle.Action.NavigateBack -> PaywallAction.NavigateBack + is ButtonComponentStyle.Action.NavigateTo -> PaywallAction.NavigateTo( + destination = destination.toPaywallDestination(localeId), + ) + is ButtonComponentStyle.Action.PurchasePackage -> PaywallAction.PurchasePackage + is ButtonComponentStyle.Action.RestorePurchases -> PaywallAction.RestorePurchases + } + + private fun ButtonComponentStyle.Action.NavigateTo.Destination.toPaywallDestination( + localeId: LocaleId, + ): PaywallAction.NavigateTo.Destination = + when (this) { + is ButtonComponentStyle.Action.NavigateTo.Destination.CustomerCenter -> + PaywallAction.NavigateTo.Destination.CustomerCenter + + is ButtonComponentStyle.Action.NavigateTo.Destination.Url -> PaywallAction.NavigateTo.Destination.Url( + // We will use the URL for the default locale if there's no URL for the current locale. + url = urls.run { getOrDefault(localeId, entry.value) }, + method = method, + ) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt index 12c3861c75..5362d93c19 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt @@ -62,6 +62,12 @@ internal fun ButtonComponentView( modifier: Modifier = Modifier, selected: Boolean = false, ) { + // Get a ButtonComponentState that calculates the stateful properties we should use. + val buttonState = rememberButtonComponentState( + style = style, + paywallState = state, + ) + val coroutineScope = rememberCoroutineScope() var isClickable by remember { mutableStateOf(true) } StackComponentView( @@ -72,7 +78,7 @@ internal fun ButtonComponentView( modifier = modifier.clickable(enabled = isClickable) { isClickable = false coroutineScope.launch { - onClick(style.action) + onClick(buttonState.action) isClickable = true } }, @@ -125,7 +131,7 @@ private fun previewButtonComponentStyle( ), overrides = null, ), - action: PaywallAction = PaywallAction.RestorePurchases, + action: ButtonComponentStyle.Action = ButtonComponentStyle.Action.RestorePurchases, ): ButtonComponentStyle { return ButtonComponentStyle( stackComponentStyle = stackComponentStyle, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt index 09a902b18b..08dd72d0a1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt @@ -1,15 +1,36 @@ package com.revenuecat.purchases.ui.revenuecatui.components.style import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.paywalls.components.ButtonComponent +import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.paywalls.components.properties.Size -import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyMap +import dev.drewhamilton.poko.Poko @Immutable internal class ButtonComponentStyle( @get:JvmSynthetic val stackComponentStyle: StackComponentStyle, @get:JvmSynthetic - val action: PaywallAction, + val action: Action, ) : ComponentStyle { + + internal sealed interface Action { + object RestorePurchases : Action + object NavigateBack : Action + object PurchasePackage : Action + + @Poko + class NavigateTo(@get:JvmSynthetic val destination: Destination) : Action { + sealed interface Destination { + object CustomerCenter : Destination + data class Url( + @get:JvmSynthetic val urls: NonEmptyMap, + @get:JvmSynthetic val method: ButtonComponent.UrlMethod, + ) : Destination + } + } + } + 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 b1d6b987ac..2374ea36b7 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 @@ -15,7 +15,6 @@ import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.paywalls.components.common.LocalizationKey import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls import com.revenuecat.purchases.ui.revenuecatui.components.LocalizedTextPartial -import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.components.PresentedImagePartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedStackPartial import com.revenuecat.purchases.ui.revenuecatui.components.SystemFontFamily @@ -43,6 +42,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf import com.revenuecat.purchases.ui.revenuecatui.helpers.orSuccessfullyNull import com.revenuecat.purchases.ui.revenuecatui.helpers.zipOrAccumulate +@Suppress("TooManyFunctions") internal class StyleFactory( private val localizations: NonEmptyMap, private val offering: Offering, @@ -73,12 +73,13 @@ internal class StyleFactory( private fun createButtonComponentStyle( component: ButtonComponent, - ): Result> = createStackComponentStyle( - component.stack, - ).map { + ): Result> = zipOrAccumulate( + first = createStackComponentStyle(component.stack), + second = component.action.toButtonComponentStyleAction(), + ) { stack, action -> ButtonComponentStyle( - stackComponentStyle = it, - action = component.action.mapButtonComponentActionToPaywallAction(), + stackComponentStyle = stack, + action = action, ) } @@ -111,18 +112,41 @@ internal class StyleFactory( ).map { ButtonComponentStyle( stackComponentStyle = it, - action = PaywallAction.PurchasePackage, + action = ButtonComponentStyle.Action.PurchasePackage, ) } - private fun ButtonComponent.Action.mapButtonComponentActionToPaywallAction(): PaywallAction { + @Suppress("MaxLineLength") + private fun ButtonComponent.Action.toButtonComponentStyleAction(): Result> { return when (this) { - ButtonComponent.Action.NavigateBack -> PaywallAction.NavigateBack - ButtonComponent.Action.RestorePurchases -> PaywallAction.RestorePurchases - is ButtonComponent.Action.NavigateTo -> PaywallAction.NavigateTo(destination) + ButtonComponent.Action.NavigateBack -> Result.Success(ButtonComponentStyle.Action.NavigateBack) + ButtonComponent.Action.RestorePurchases -> Result.Success(ButtonComponentStyle.Action.RestorePurchases) + is ButtonComponent.Action.NavigateTo -> destination.toPaywallDestination() + .map { ButtonComponentStyle.Action.NavigateTo(it) } } } + @Suppress("MaxLineLength") + private fun ButtonComponent.Destination.toPaywallDestination(): Result> = + + when (this) { + is ButtonComponent.Destination.CustomerCenter -> Result.Success( + ButtonComponentStyle.Action.NavigateTo.Destination.CustomerCenter, + ) + + is ButtonComponent.Destination.PrivacyPolicy -> buttonComponentStyleUrlDestination(urlLid, method) + is ButtonComponent.Destination.Terms -> buttonComponentStyleUrlDestination(urlLid, method) + is ButtonComponent.Destination.Url -> buttonComponentStyleUrlDestination(urlLid, method) + } + + private fun buttonComponentStyleUrlDestination( + urlLid: LocalizationKey, + method: ButtonComponent.UrlMethod, + ) = + localizations.stringForAllLocales(urlLid).map { urls -> + ButtonComponentStyle.Action.NavigateTo.Destination.Url(urls, method) + } + private fun createStackComponentStyle( component: StackComponent, ): Result> = zipOrAccumulate( diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt index 330ec8b918..c9f9fa464c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt @@ -119,13 +119,24 @@ class PaywallActionTests { private fun PaywallAction.toButtonAction(): ButtonComponent.Action = when (this) { is PaywallAction.NavigateBack -> ButtonComponent.Action.NavigateBack - is PaywallAction.NavigateTo -> ButtonComponent.Action.NavigateTo(destination) + is PaywallAction.NavigateTo -> ButtonComponent.Action.NavigateTo(destination.toButtonDestination()) is PaywallAction.RestorePurchases -> ButtonComponent.Action.RestorePurchases is PaywallAction.PurchasePackage -> error( "PurchasePackage is not a ButtonComponent.Action. It is handled by PurchaseButtonComponent instead." ) } + private fun PaywallAction.NavigateTo.Destination.toButtonDestination(): ButtonComponent.Destination = + when (this) { + is PaywallAction.NavigateTo.Destination.CustomerCenter -> ButtonComponent.Destination.CustomerCenter + is PaywallAction.NavigateTo.Destination.Url -> ButtonComponent.Destination.Url( + // We are treating the actual URL as a LocalizationKey here, which is not correct. However the actual + // LocalizationKey is not known here, and this is sufficient for our tests. + urlLid = LocalizationKey(url), + method = method + ) + } + @Suppress("TestFunctionName") private fun FakePaywallData( components: List, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentViewTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentViewTests.kt index 483b6b4aef..96835da6c4 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentViewTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentViewTests.kt @@ -1,15 +1,24 @@ package com.revenuecat.purchases.ui.revenuecatui.components.button +import android.os.LocaleList import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.paywalls.components.ButtonComponent +import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey import com.revenuecat.purchases.paywalls.components.properties.Border import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme @@ -25,12 +34,15 @@ import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fi import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toAlignment import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toFontWeight +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toPaddingValues import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toTextAlign import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.StyleFactory import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.helpers.FakePaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf import kotlinx.coroutines.CompletableDeferred import org.assertj.core.api.Assertions.assertThat @@ -89,7 +101,7 @@ class ButtonComponentViewTests { ), overrides = null, ), - action = PaywallAction.PurchasePackage, + action = ButtonComponentStyle.Action.PurchasePackage, ) ButtonComponentView( style = style, @@ -118,4 +130,80 @@ class ButtonComponentViewTests { assertThat(actionHandleCalledCount).isEqualTo(2) } + @Test + fun `Should use the correct URL when the locale changes`(): Unit = with(composeTestRule) { + val localeIdEnUs = LocaleId("en_US") + val localeIdNlNl = LocaleId("nl_NL") + val localizationKey = LocalizationKey("ineligible key") + val expectedUrlEnUs = "expected" + val expectedUrlNlNl = "verwacht" + val component = ButtonComponent( + action = ButtonComponent.Action.NavigateTo( + destination = ButtonComponent.Destination.Url( + urlLid = localizationKey, + method = ButtonComponent.UrlMethod.EXTERNAL_BROWSER, + ) + ), + stack = StackComponent( + components = listOf( + TextComponent( + text = localizationKey, + color = ColorScheme(light = ColorInfo.Hex(Color.White.toArgb())), + ) + ) + ), + ) + val localizations = nonEmptyMapOf( + localeIdEnUs to nonEmptyMapOf( + localizationKey to LocalizationData.Text(expectedUrlEnUs), + ), + localeIdNlNl to nonEmptyMapOf( + localizationKey to LocalizationData.Text(expectedUrlNlNl), + ) + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = emptyList(), + ) + val styleFactory = StyleFactory(localizations, offering) + val style = styleFactory.create(component).getOrThrow() as ButtonComponentStyle + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + component + ) + + // Act + var clickedUrl: String? = null + setContent { + ButtonComponentView( + style = style, + onClick = { action -> + clickedUrl = action + .let { it as? PaywallAction.NavigateTo } + ?.let { it.destination as PaywallAction.NavigateTo.Destination.Url } + ?.url + }, + state = state + ) + } + + // Assert + state.update(localeList = LocaleList(localeIdEnUs.toJavaLocale())) + onNodeWithText(expectedUrlEnUs) + .assertIsDisplayed() + .assertHasClickAction() + .performClick() + assertThat(clickedUrl).isEqualTo(expectedUrlEnUs) + + state.update(localeList = LocaleList(localeIdNlNl.toJavaLocale())) + onNodeWithText(expectedUrlNlNl) + .assertIsDisplayed() + .assertHasClickAction() + .performClick() + assertThat(clickedUrl).isEqualTo(expectedUrlNlNl) + } + } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index cababbc46a..08c6c21651 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PartialImageComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent @@ -189,6 +190,47 @@ class StyleFactoryTests { assertThat(error).isInstanceOf(PaywallValidationError.MissingStringLocalization::class.java) } + @Test + fun `Should fail to create a ButtonComponentStyle if localized URL is missing`() { + // Arrange + val otherLocale = LocaleId("nl_NL") + val defaultLocale = LocaleId("en_US") + val localizationKey = LocalizationKey("key") + val otherLocalizationKey = LocalizationKey("other-key") + val expectedText = "value" + val unexpectedText = "waarde" + val component = ButtonComponent( + action = ButtonComponent.Action.NavigateTo( + destination = ButtonComponent.Destination.Url( + urlLid = localizationKey, + method = ButtonComponent.UrlMethod.EXTERNAL_BROWSER, + ) + ), + stack = StackComponent(components = emptyList()), + ) + val incorrectStyleFactory = StyleFactory( + localizations = nonEmptyMapOf( + defaultLocale to nonEmptyMapOf( + localizationKey to LocalizationData.Text(expectedText) + ), + otherLocale to nonEmptyMapOf( + otherLocalizationKey to LocalizationData.Text(unexpectedText) + ), + ), + offering = offering, + ) + + // Act + val result = incorrectStyleFactory.create(component) + + // Assert + assertThat(result.isError).isTrue() + val errors = result.errorOrNull()!! + assertThat(errors.size).isEqualTo(1) + val error = errors[0] + assertThat(error).isInstanceOf(PaywallValidationError.MissingStringLocalization::class.java) + } + @Test fun `Should pair the default image with the default locale if there are no localized images`() { val defaultLocale = LocaleId("en_US") From 7410ee41fe572c44210ff203fcb38ef897d63838 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:31:34 +0100 Subject: [PATCH 13/20] Updates compose-bom to 2024.09.00. --- gradle/libs.versions.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70fb7c30fb..b6a6f050e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -124,8 +124,7 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve kotlin-bom = "org.jetbrains.kotlin:kotlin-bom:1.8.0" kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -# When we update to 2024.09.00 or higher, we can remove the version from compose-material3Adaptive below. -compose-bom = "androidx.compose:compose-bom:2024.08.00" +compose-bom = "androidx.compose:compose-bom:2024.09.00" compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-util = { module = "androidx.compose.ui:ui-util" } compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } @@ -134,7 +133,7 @@ compose-ui-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" compose-material = { module = "androidx.compose.material:material" } compose-material3 = { module = "androidx.compose.material3:material3" } compose-window-size = { module = "androidx.compose.material3:material3-window-size-class" } -compose-material3Adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version = "1.0.0" } +compose-material3Adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } window = { module = "androidx.window:window", version.ref = "window" } From 609550eea6869c6bd04f18c417fd7c2e2a83fb5c Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:32:08 +0100 Subject: [PATCH 14/20] Fixes Markdown consuming all click actions, even without links. --- .../ui/revenuecatui/composables/Markdown.kt | 41 +++---------------- 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Markdown.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Markdown.kt index 5fa5bbb8f5..1b041ce936 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Markdown.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Markdown.kt @@ -2,7 +2,6 @@ package com.revenuecat.purchases.ui.revenuecatui.composables -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,19 +10,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -31,6 +27,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp @@ -57,7 +54,6 @@ import org.commonmark.parser.Parser // Inspired by https://github.com/ErikHellman/MarkdownComposer/blob/master/app/src/main/java/se/hellsoft/markdowncomposer/MarkdownComposer.kt -private const val TAG_URL = "url" private val parser = Parser.builder() .extensions(listOf(StrikethroughExtension.create())) .build() @@ -199,7 +195,6 @@ private fun MDHeading( fontWeight, fontFamily, textAlign, - allowLinks, textFillMaxWidth, ) } @@ -240,7 +235,6 @@ private fun MDParagraph( fontWeight, fontFamily, textAlign, - allowLinks, textFillMaxWidth, ) } @@ -285,7 +279,6 @@ private fun MDBulletList( fontWeight, fontFamily, textAlign, - allowLinks, textFillMaxWidth, ) } @@ -331,7 +324,6 @@ private fun MDOrderedList( fontWeight, fontFamily, textAlign, - allowLinks, textFillMaxWidth, ) } @@ -532,11 +524,9 @@ private fun AnnotatedString.Builder.appendMarkdownChildren( is Link -> { if (allowLinks) { val underline = SpanStyle(color, textDecoration = TextDecoration.Underline) - pushStyle(underline) - pushStringAnnotation(TAG_URL, child.destination) - appendMarkdownChildren(child, color, allowLinks = true) - pop() - pop() + withLink(LinkAnnotation.Url(child.destination, TextLinkStyles(underline))) { + appendMarkdownChildren(child, color, allowLinks = true) + } } else { appendMarkdownChildren(child, color, allowLinks = false) } @@ -561,13 +551,9 @@ private fun MarkdownText( fontWeight: FontWeight?, fontFamily: FontFamily?, textAlign: TextAlign?, - allowLinks: Boolean, textFillMaxWidth: Boolean, modifier: Modifier = Modifier, ) { - val layoutResult = remember { mutableStateOf(null) } - val uriHandler = LocalUriHandler.current - Text( text = text, color = color, @@ -579,21 +565,6 @@ private fun MarkdownText( modifier = modifier .conditional(textFillMaxWidth) { fillMaxWidth() - } - .conditional(allowLinks) { - pointerInput(Unit) { - detectTapGestures { offset -> - layoutResult.value?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - text.getStringAnnotations(position, position) - .firstOrNull { it.tag == TAG_URL } - ?.let { - uriHandler.openUri(it.item) - } - } - } - } }, - onTextLayout = { layoutResult.value = it }, ) } From b115f58f71005425553cdc3d60e89f9aa0b3af34 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:33:19 +0100 Subject: [PATCH 15/20] Adds another strikethrough preview. --- .../ui/revenuecatui/components/text/TextComponentView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index 2c3bb5a73a..d17533a8b2 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -116,7 +116,7 @@ internal fun TextComponentView( fontSize = textState.fontSize.toTextUnit(), fontWeight = textState.fontWeight, fontFamily = textState.fontFamily, - horizontalAlignment = textState.horizontalAlignment, + // horizontalAlignment = textState.horizontalAlignment, textAlign = textState.textAlign, style = textStyle, ) @@ -304,7 +304,7 @@ private fun TextComponentView_Preview_Customizations() { private fun TextComponentView_Preview_Markdown() { TextComponentView( style = previewTextComponentStyle( - text = "Hello, **bold**, *italic* or _italic2_ with ~strikethrough~ and `monospace`. " + + text = "Hello, **bold**, *italic* or _italic2_ with ~strikethrough~, ~~strikethrough2~~ and `monospace`. " + "Click [here](https://revenuecat.com)", color = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())), ), From 8908a6b88fb884452910619ed6fff357d86b8a42 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:32:21 +0100 Subject: [PATCH 16/20] Fixes PaywallActionTests. --- .../purchases/ui/revenuecatui/components/PaywallActionTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt index c28b4beaae..e0bc819470 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt @@ -134,7 +134,7 @@ class PaywallActionTests { when (this) { is PaywallAction.NavigateBack -> ButtonComponent.Action.NavigateBack is PaywallAction.NavigateTo -> ButtonComponent.Action.NavigateTo(destination) - is PaywallAction.RestorePurchases -> ButtonComponent.Action.NavigateBack + is PaywallAction.RestorePurchases -> ButtonComponent.Action.RestorePurchases is PaywallAction.PurchasePackage -> error( "PurchasePackage is not a ButtonComponent.Action. It is handled by PurchaseButtonComponent instead." ) From 87bc1a9b4001ffb2fe56abd5ff607b4772e8b9c7 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Wed, 8 Jan 2025 16:38:55 +0100 Subject: [PATCH 17/20] Adds Custom Tabs to support in-app browser URL destinations. --- gradle/libs.versions.toml | 1 + ui/revenuecatui/build.gradle | 1 + .../ui/revenuecatui/InternalPaywall.kt | 34 +++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6a6f050e7..5068b767b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ poko = { id = "dev.drewhamilton.poko", version = "0.13.1" } [libraries] androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.3" androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-datastore-preferences = "androidx.datastore:datastore-preferences:1.0.0" diff --git a/ui/revenuecatui/build.gradle b/ui/revenuecatui/build.gradle index 9b41036fe7..f1d8ab63b7 100644 --- a/ui/revenuecatui/build.gradle +++ b/ui/revenuecatui/build.gradle @@ -80,6 +80,7 @@ dependencies { implementation libs.activity.compose implementation libs.androidx.fragment.ktx implementation libs.compose.ui.google.fonts + implementation libs.androidx.browser debugImplementation libs.compose.ui.tooling debugImplementation libs.androidx.test.compose.manifest diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index 9b6f4df6d3..3c1497f856 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -5,8 +5,10 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper import android.content.res.Configuration +import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -288,19 +290,31 @@ private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend ( } private fun Context.handleUrlDestination(url: String, method: ButtonComponent.UrlMethod) { + fun handleException(exception: Exception) { + val message = if (exception is ActivityNotFoundException) { + getString(R.string.no_browser_cannot_open_link) + } else { + getString(R.string.cannot_open_link) + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + Logger.e(message, exception) + } + when (method) { - ButtonComponent.UrlMethod.IN_APP_BROWSER -> TODO() - ButtonComponent.UrlMethod.EXTERNAL_BROWSER, - ButtonComponent.UrlMethod.DEEP_LINK, - -> openUriOrElse(url) { exception -> - val message = if (exception is ActivityNotFoundException) { - getString(R.string.no_browser_cannot_open_link) - } else { - getString(R.string.cannot_open_link) + ButtonComponent.UrlMethod.IN_APP_BROWSER -> { + val intent = CustomTabsIntent.Builder() + .build() + @Suppress("TooGenericExceptionCaught") + try { + intent.launchUrl(this, Uri.parse(url)) + } catch (e: Exception) { + handleException(e) } - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - Logger.w(message) } + + ButtonComponent.UrlMethod.EXTERNAL_BROWSER, + ButtonComponent.UrlMethod.DEEP_LINK, + -> openUriOrElse(url, ::handleException) } } From 3684f8b66aa1eaa9c1ad2c2d3f9e63be345f2c8b Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Wed, 8 Jan 2025 18:51:46 +0100 Subject: [PATCH 18/20] Uncomments the same thing again. --- .../ui/revenuecatui/components/text/TextComponentView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index ed48cd6bc5..f7e030b628 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -116,7 +116,7 @@ internal fun TextComponentView( fontSize = textState.fontSize.toTextUnit(), fontWeight = textState.fontWeight, fontFamily = textState.fontFamily, - // horizontalAlignment = textState.horizontalAlignment, + horizontalAlignment = textState.horizontalAlignment, textAlign = textState.textAlign, style = textStyle, ) From a0be25847bb1c972b4c1a27f3fc527117a3a2d91 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:36:41 +0100 Subject: [PATCH 19/20] Fixes tests. --- .../paywalls/components/ButtonComponentTests.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/ButtonComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/ButtonComponentTests.kt index 62039d45c6..142a9fd63c 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/ButtonComponentTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/ButtonComponentTests.kt @@ -195,7 +195,7 @@ internal class ButtonComponentTests { expected = ButtonComponent( action = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.PrivacyPolicy( - urlLid = "ef54", + urlLid = LocalizationKey("ef54"), method = ButtonComponent.UrlMethod.IN_APP_BROWSER ) ), @@ -247,7 +247,7 @@ internal class ButtonComponentTests { expected = ButtonComponent( action = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.Terms( - urlLid = "ef64", + urlLid = LocalizationKey("ef64"), method = ButtonComponent.UrlMethod.EXTERNAL_BROWSER ) ), @@ -299,7 +299,7 @@ internal class ButtonComponentTests { expected = ButtonComponent( action = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.Url( - urlLid = "ef74", + urlLid = LocalizationKey("ef74"), method = ButtonComponent.UrlMethod.DEEP_LINK ) ), @@ -432,7 +432,7 @@ internal class ButtonComponentTests { """.trimIndent(), deserialized = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.PrivacyPolicy( - urlLid = "ef54", + urlLid = LocalizationKey("ef54"), method = ButtonComponent.UrlMethod.IN_APP_BROWSER ) ), @@ -453,7 +453,7 @@ internal class ButtonComponentTests { """.trimIndent(), deserialized = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.Terms( - urlLid = "ef64", + urlLid = LocalizationKey("ef64"), method = ButtonComponent.UrlMethod.EXTERNAL_BROWSER ) ) @@ -474,7 +474,7 @@ internal class ButtonComponentTests { """.trimIndent(), deserialized = ButtonComponent.Action.NavigateTo( destination = ButtonComponent.Destination.Url( - urlLid = "ef74", + urlLid = LocalizationKey("ef74"), method = ButtonComponent.UrlMethod.DEEP_LINK ) ) From b0f547fe0c8b7eadd1919ea1b5222cd86f3db534 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:34:02 +0100 Subject: [PATCH 20/20] Catches known exceptions only. --- .../revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index 3c1497f856..d9ce3cf338 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -307,7 +307,9 @@ private fun Context.handleUrlDestination(url: String, method: ButtonComponent.Ur @Suppress("TooGenericExceptionCaught") try { intent.launchUrl(this, Uri.parse(url)) - } catch (e: Exception) { + } catch (e: ActivityNotFoundException) { + handleException(e) + } catch (e: IllegalArgumentException) { handleException(e) } }