Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: Add PaywallFooter composable to present a minified paywall UI that allows for custom paywalls #1314

Merged
merged 5 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
package com.revenuecat.purchases.ui.revenuecatui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewModel
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewModelFactory
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewModelImpl
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewState
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.helpers.isInPreviewMode
import com.revenuecat.purchases.ui.revenuecatui.helpers.toAndroidContext
import com.revenuecat.purchases.ui.revenuecatui.templates.Template1
Expand All @@ -20,50 +27,63 @@ import com.revenuecat.purchases.ui.revenuecatui.templates.Template3

@Composable
internal fun InternalPaywallView(
mode: PaywallViewMode = PaywallViewMode.default,
offering: Offering? = null,
listener: PaywallViewListener? = null,
viewModel: PaywallViewModel = getPaywallViewModel(offering = offering, listener = listener, mode = mode),
options: PaywallViewOptions,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note how I've changed from passing each of these separately to passing them all as part of the PaywallViewOptions. Since we need most of these from the view model anyway, I think this is cleaner.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice good refactor.

viewModel: PaywallViewModel = getPaywallViewModel(options),
) {
viewModel.refreshStateIfLocaleChanged()
val colors = MaterialTheme.colorScheme
viewModel.refreshStateIfColorsChanged(colors)

when (val state = viewModel.state.collectAsState().value) {
is PaywallViewState.Loading -> {
LoadingPaywallView(mode = mode)
LoadingPaywallView(mode = options.mode)
}
is PaywallViewState.Error -> {
Text(text = "Error: ${state.errorMessage}")
}
is PaywallViewState.Loaded -> {
when (state.templateConfiguration.template) {
PaywallTemplate.TEMPLATE_1 -> Template1(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_2 -> Template2(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_3 -> Template3(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_4 -> Text(text = "Error: Template 4 not supported")
PaywallTemplate.TEMPLATE_5 -> Text(text = "Error: Template 5 not supported")
val backgroundColor = state.templateConfiguration.getCurrentColors().background
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't currently used so I added it here to be able to test the footer better. Note that I'm applying to the view wrapping the templates, so it applies to all templates. We might want to move it inside the templates, depending on its usage.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Box(
modifier = Modifier
.conditional(state.isInFullScreenMode) {
Modifier.background(backgroundColor)
}
.conditional(!state.isInFullScreenMode) {
Modifier
.clip(
RoundedCornerShape(
topStart = footerRoundedBorderHeight,
topEnd = footerRoundedBorderHeight,
),
)
.background(backgroundColor)
.padding(top = footerRoundedBorderHeight)
},
) {
when (state.templateConfiguration.template) {
PaywallTemplate.TEMPLATE_1 -> Template1(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_2 -> Template2(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_3 -> Template3(state = state, viewModel = viewModel)
PaywallTemplate.TEMPLATE_4 -> Text(text = "Error: Template 4 not supported")
PaywallTemplate.TEMPLATE_5 -> Text(text = "Error: Template 5 not supported")
}
tonidero marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

@Composable
private fun getPaywallViewModel(
mode: PaywallViewMode,
offering: Offering?,
listener: PaywallViewListener?,
options: PaywallViewOptions,
): PaywallViewModel {
val applicationContext = LocalContext.current.applicationContext
return viewModel<PaywallViewModelImpl>(
// We need to pass the key in order to create different view models for different offerings when
// trying to load different paywalls for the same view model store owner.
key = offering?.identifier,
key = options.offering?.identifier,
factory = PaywallViewModelFactory(
applicationContext.toAndroidContext(),
mode,
offering,
listener,
options,
MaterialTheme.colorScheme,
preview = isInPreviewMode(),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.revenuecat.purchases.ui.revenuecatui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.ui.revenuecatui.helpers.isInPreviewMode

internal val footerRoundedBorderHeight = 12.dp
tonidero marked this conversation as resolved.
Show resolved Hide resolved

@Composable
fun PaywallFooter(
paywallViewOptions: PaywallViewOptions = PaywallViewOptions.Builder().build(),
condensed: Boolean = false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condensed parameter right now doesn't affect the UI. That will happen in future PRs

mainContent: @Composable (PaddingValues) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
// This is a workaround to make the main content be able to go below the footer, so it's visible through
// the rounded corners. We pass this padding back to the developer so they can add this padding to their content
verticalArrangement = Arrangement.spacedBy(-footerRoundedBorderHeight),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
mainContent(PaddingValues(bottom = footerRoundedBorderHeight))
}
val mode = if (condensed) PaywallViewMode.FOOTER_CONDENSED else PaywallViewMode.FOOTER
tonidero marked this conversation as resolved.
Show resolved Hide resolved
if (isInPreviewMode()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color.Blue),
)
tonidero marked this conversation as resolved.
Show resolved Hide resolved
} else {
PaywallView(paywallViewOptions.changeMode(mode))
}
}
}

@Suppress("MagicNumber")
@Preview(showBackground = true)
@Composable
private fun PaywallFooterPreview() {
PaywallFooter {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(it),
) {
// TODO-PAYWALLS: Implement an actual sample paywall
for (i in 1..50) {
Text(text = "Main content $i")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,5 @@ import androidx.compose.runtime.Composable
fun PaywallView(
options: PaywallViewOptions = PaywallViewOptions.Builder().build(),
) {
InternalPaywallView(
offering = options.offering,
listener = options.listener,
)
InternalPaywallView(options)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ internal enum class PaywallViewMode {
companion object {
val default = FULL_SCREEN
}

val isFullScreen: Boolean
get() = when (this) {
FULL_SCREEN -> true
FOOTER, FOOTER_CONDENSED -> false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,29 @@ class PaywallViewOptions(builder: Builder) {
val offering: Offering?
val shouldDisplayDismissButton: Boolean
val listener: PaywallViewListener?
internal val mode: PaywallViewMode

init {
this.offering = builder.offering
this.shouldDisplayDismissButton = builder.shouldDisplayDismissButton
this.listener = builder.listener
this.mode = builder.mode
}

internal fun changeMode(mode: PaywallViewMode): PaywallViewOptions {
return Builder()
.setOffering(offering)
.setListener(listener)
.setShouldDisplayDismissButton(shouldDisplayDismissButton)
.setMode(mode)
.build()
}

class Builder {
internal var offering: Offering? = null
internal var shouldDisplayDismissButton: Boolean = false
internal var listener: PaywallViewListener? = null
internal var mode: PaywallViewMode = PaywallViewMode.default

fun setOffering(offering: Offering?) = apply {
this.offering = offering
Expand All @@ -31,6 +43,10 @@ class PaywallViewOptions(builder: Builder) {
this.listener = listener
}

internal fun setMode(mode: PaywallViewMode) = apply {
this.mode = mode
}

fun build(): PaywallViewOptions {
return PaywallViewOptions(this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -43,7 +43,7 @@ internal fun IconImage(
Box(
modifier = modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxSize(),
.size(maxWidth),
)
} else if (uri.toString().contains(PaywallData.defaultAppIconPlaceholder)) {
AppIcon(modifier = modifier)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.revenuecat.purchases.ui.revenuecatui.composables

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
Expand All @@ -12,12 +12,12 @@ import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfigura
import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional

@Composable
internal fun PaywallBackground(templateConfiguration: TemplateConfiguration) {
internal fun BoxScope.PaywallBackground(templateConfiguration: TemplateConfiguration) {
templateConfiguration.images.backgroundUri?.let {
RemoteImage(
urlString = it.toString(),
modifier = Modifier
.fillMaxSize()
.matchParentSize()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was running into a few issues with the fillMaxSize in the footer, since that's not actually what we want. This works within a BoxScope so I just added that since we will probably always be using the PaywallBackground from within a Box

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍🏻

.conditional(templateConfiguration.configuration.blurredBackgroundImage) {
// TODO-PAYWALLS: backwards compatibility for blurring
blur(BackgroundUIConstants.blurSize, edgeTreatment = BlurredEdgeTreatment.Unbounded)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewListener
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewMode
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewOptions
import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider
import com.revenuecat.purchases.ui.revenuecatui.extensions.getActivity
Expand Down Expand Up @@ -52,9 +53,7 @@ internal interface PaywallViewModel {
@Suppress("TooManyFunctions")
internal class PaywallViewModelImpl(
applicationContext: ApplicationContext,
private val mode: PaywallViewMode,
private val offering: Offering?,
private val listener: PaywallViewListener?,
private val options: PaywallViewOptions,
colorScheme: ColorScheme,
preview: Boolean = false,
) : ViewModel(), PaywallViewModel {
Expand All @@ -67,6 +66,15 @@ internal class PaywallViewModelImpl(
private val _lastLocaleList = MutableStateFlow(getCurrentLocaleList())
private val _colorScheme = MutableStateFlow(colorScheme)

private val offering: Offering?
get() = options.offering

private val listener: PaywallViewListener?
get() = options.listener

private val mode: PaywallViewMode
get() = options.mode

init {
_state = MutableStateFlow(offering?.calculateState() ?: PaywallViewState.Loading)
if (offering == null) {
Expand Down Expand Up @@ -131,10 +139,11 @@ internal class PaywallViewModelImpl(
}

private fun refreshState() {
if (offering == null) {
val currentOffering = offering
if (currentOffering == null) {
updateOffering()
} else {
_state.value = offering.calculateState()
_state.value = currentOffering.calculateState()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,16 @@ package com.revenuecat.purchases.ui.revenuecatui.data
import androidx.compose.material3.ColorScheme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewListener
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewMode
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewOptions
import com.revenuecat.purchases.ui.revenuecatui.helpers.ApplicationContext

internal class PaywallViewModelFactory(
private val applicationContext: ApplicationContext,
private val mode: PaywallViewMode,
private val offering: Offering?,
private val listener: PaywallViewListener?,
private val options: PaywallViewOptions,
private val colorScheme: ColorScheme,
private val preview: Boolean = false,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PaywallViewModelImpl(applicationContext, mode, offering, listener, colorScheme, preview) as T
return PaywallViewModelImpl(applicationContext, options, colorScheme, preview) as T
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.revenuecat.purchases.ui.revenuecatui.data

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewMode
import com.revenuecat.purchases.ui.revenuecatui.data.processed.ProcessedLocalizedConfiguration
import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration

Expand All @@ -20,3 +21,6 @@ internal val PaywallViewState.Loaded.selectedLocalization: ProcessedLocalizedCon
internal val PaywallViewState.Loaded.currentColors: TemplateConfiguration.Colors
@Composable @ReadOnlyComposable
get() = templateConfiguration.getCurrentColors()

internal val PaywallViewState.Loaded.isInFullScreenMode: Boolean
get() = templateConfiguration.mode == PaywallViewMode.FULL_SCREEN
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.revenuecat.purchases.Package
import com.revenuecat.purchases.paywalls.PaywallColor
import com.revenuecat.purchases.paywalls.PaywallData
import com.revenuecat.purchases.ui.revenuecatui.InternalPaywallView
import com.revenuecat.purchases.ui.revenuecatui.PaywallViewOptions
import com.revenuecat.purchases.ui.revenuecatui.data.processed.PaywallTemplate
import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData
import java.net.URL
Expand Down Expand Up @@ -125,5 +126,5 @@ internal fun Template2PaywallPreview() {
paywall = paywallData,
serverDescription = "",
)
InternalPaywallView(offering = template2Offering)
InternalPaywallView(options = PaywallViewOptions.Builder().setOffering(template2Offering).build())
}
Loading