From b83197237479f2ae6f3e26cefb6b9c5895beedeb Mon Sep 17 00:00:00 2001 From: Cesar de la Vega Date: Fri, 20 Dec 2024 15:12:39 +0100 Subject: [PATCH] Build Customer Center from JSON (#1998) --- .../CustomerCenterConfigData.kt | 14 +- .../BackendGetCustomerCenterConfigTest.kt | 12 +- .../CustomerCenterConfigDataTest.kt | 3 +- .../get_customer_center_config_success.json | 32 ++- .../customercenter/InternalCustomerCenter.kt | 80 ++++-- .../customercenter/SubscriptionDetailsView.kt | 14 +- .../data/CustomerCenterConfigTestData.kt | 129 +++++++++ .../data/CustomerCenterState.kt | 8 +- .../data/CustomerCenterViewModel.kt | 38 --- ...nInformation.kt => PurchaseInformation.kt} | 2 +- .../viewmodel/CustomerCenterViewModel.kt | 63 +++++ .../CustomerCenterViewModelFactory.kt | 2 +- .../views/ManageSubscriptionsView.kt | 246 ++++++++++++++++++ 13 files changed, 569 insertions(+), 74 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt delete mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModel.kt rename ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/{SubscriptionInformation.kt => PurchaseInformation.kt} (84%) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt rename ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/{data => viewmodel}/CustomerCenterViewModelFactory.kt (98%) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/ManageSubscriptionsView.kt diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt index 91a39b9fb6..2c4858d05c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt @@ -207,6 +207,7 @@ data class CustomerCenterConfigData( val eligible: Boolean, val title: String, val subtitle: String, + @SerialName("product_mapping") val productMapping: Map, ) : PathDetail() @Serializable @@ -270,6 +271,17 @@ data class CustomerCenterConfigData( @Serializable data class Support( - @Serializable(with = EmptyStringToNullSerializer::class) val email: String? = null, + @Serializable(with = EmptyStringToNullSerializer::class) + val email: String? = null, + @SerialName("should_warn_customer_to_update") + val shouldWarnCustomerToUpdate: Boolean? = null, ) + + fun getManagementScreen(): CustomerCenterConfigData.Screen? { + return screens[CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT] + } + + fun getNoActiveScreen(): CustomerCenterConfigData.Screen? { + return screens[CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE] + } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt index e28a7d4895..d37a2d5287 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt @@ -66,7 +66,8 @@ class BackendGetCustomerCenterConfigTest { androidOfferId = "rc-refund-offer", eligible = true, title = "Wait!", - subtitle = "Before you go, here's a one-time offer to continue at a discount." + subtitle = "Before you go, here's a one-time offer to continue at a discount.", + productMapping = mapOf("monthly_subscription" to "rc-refund-offer") ) ), HelpPath( @@ -88,7 +89,8 @@ class BackendGetCustomerCenterConfigTest { androidOfferId = "rc-cancel-offer", eligible = true, title = "Wait!", - subtitle = "Before you go, here's a one-time offer to continue at a discount." + subtitle = "Before you go, here's a one-time offer to continue at a discount.", + productMapping = mapOf("monthly_subscription" to "rc-cancel-offer") ) ), HelpPath.PathDetail.FeedbackSurvey.Option( @@ -98,7 +100,8 @@ class BackendGetCustomerCenterConfigTest { androidOfferId = "rc-cancel-offer", eligible = true, title = "Wait!", - subtitle = "Before you go, here's a one-time offer to continue at a discount." + subtitle = "Before you go, here's a one-time offer to continue at a discount.", + productMapping = mapOf("monthly_subscription" to "rc-cancel-offer") ) ), HelpPath.PathDetail.FeedbackSurvey.Option( @@ -180,7 +183,8 @@ class BackendGetCustomerCenterConfigTest { ) ), support = CustomerCenterConfigData.Support( - email = "support@revenuecat.com" + email = "support@revenuecat.com", + shouldWarnCustomerToUpdate = true ), lastPublishedAppVersion = null ) diff --git a/purchases/src/test/java/com/revenuecat/purchases/customercenter/CustomerCenterConfigDataTest.kt b/purchases/src/test/java/com/revenuecat/purchases/customercenter/CustomerCenterConfigDataTest.kt index dec3d8877c..967627cef8 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/customercenter/CustomerCenterConfigDataTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/customercenter/CustomerCenterConfigDataTest.kt @@ -88,7 +88,8 @@ class CustomerCenterConfigDataTest { androidOfferId = "offer_id", eligible = true, title = "Offer Title", - subtitle = "Offer Subtitle" + subtitle = "Offer Subtitle", + productMapping = mapOf("monthly_subscription" to "rc-refund-offer") ) ) diff --git a/purchases/src/test/resources/get_customer_center_config_success.json b/purchases/src/test/resources/get_customer_center_config_success.json index fb534f3186..8a4190d9bd 100644 --- a/purchases/src/test/resources/get_customer_center_config_success.json +++ b/purchases/src/test/resources/get_customer_center_config_success.json @@ -70,7 +70,10 @@ "eligible":true, "android_offer_id":"rc-refund-offer", "subtitle":"Before you go, here's a one-time offer to continue at a discount.", - "title":"Wait!" + "title":"Wait!", + "product_mapping":{ + "monthly_subscription":"rc-refund-offer" + } }, "title":"Request a refund", "type":"REFUND_REQUEST" @@ -89,7 +92,10 @@ "eligible":true, "android_offer_id":"rc-cancel-offer", "subtitle":"Before you go, here's a one-time offer to continue at a discount.", - "title":"Wait!" + "title":"Wait!", + "product_mapping":{ + "monthly_subscription":"rc-cancel-offer" + } }, "title":"Too expensive" }, @@ -99,7 +105,10 @@ "eligible":true, "android_offer_id":"rc-cancel-offer", "subtitle":"Before you go, here's a one-time offer to continue at a discount.", - "title":"Wait!" + "title":"Wait!", + "product_mapping":{ + "monthly_subscription":"rc-cancel-offer" + } }, "title":"Don't use the app" }, @@ -113,6 +122,13 @@ "id":"jnkasldfhas", "title":"Cancel subscription", "type":"CANCEL" + }, + { + "id": "path_ZD-yiHSBN", + "open_method": "EXTERNAL", + "title": "RevenueCat", + "type": "CUSTOM_URL", + "url": "https://revenuecat.com" } ], "title":"How can we help?", @@ -124,6 +140,13 @@ "id":"9q9719171o", "title":"Check purchases", "type":"MISSING_PURCHASE" + }, + { + "id": "path_ZD-yiHSDN", + "open_method": "EXTERNAL", + "title": "RevenueCat", + "type": "CUSTOM_URL", + "url": "https://revenuecat.com" } ], "subtitle":"You currently have no active subscriptions", @@ -132,7 +155,8 @@ } }, "support":{ - "email":"support@revenuecat.com" + "email":"support@revenuecat.com", + "should_warn_customer_to_update": true } }, "itunes_track_id":null, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt index ad58b7cfb3..584a400156 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) + package com.revenuecat.purchases.ui.revenuecatui.customercenter import androidx.compose.foundation.layout.Arrangement @@ -8,23 +10,25 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterConfigTestData import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterState -import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterViewModel -import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterViewModelFactory -import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterViewModelImpl +import com.revenuecat.purchases.ui.revenuecatui.customercenter.viewmodel.CustomerCenterViewModel +import com.revenuecat.purchases.ui.revenuecatui.customercenter.viewmodel.CustomerCenterViewModelFactory +import com.revenuecat.purchases.ui.revenuecatui.customercenter.viewmodel.CustomerCenterViewModelImpl +import com.revenuecat.purchases.ui.revenuecatui.customercenter.views.ManageSubscriptionsView import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesImpl import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType +import kotlinx.coroutines.launch @Composable internal fun InternalCustomerCenter( @@ -32,19 +36,29 @@ internal fun InternalCustomerCenter( viewModel: CustomerCenterViewModel = getCustomerCenterViewModel(), ) { val state by viewModel.state.collectAsState() - InternalCustomerCenter(state, modifier) + val coroutineScope = rememberCoroutineScope() + InternalCustomerCenter( + state, + modifier, + onDetermineFlow = { path -> + coroutineScope.launch { + viewModel.determineFlow(path) + } + }, + ) } @Composable private fun InternalCustomerCenter( state: CustomerCenterState, modifier: Modifier = Modifier, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, ) { CustomerCenterScaffold(modifier) { when (state) { is CustomerCenterState.Loading -> CustomerCenterLoading() is CustomerCenterState.Error -> CustomerCenterError(state) - is CustomerCenterState.Success -> CustomerCenterLoaded(state) + is CustomerCenterState.Success -> CustomerCenterLoaded(state, onDetermineFlow) } } } @@ -75,12 +89,34 @@ private fun CustomerCenterError(state: CustomerCenterState.Error) { Text("Error: ${state.error}") } +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Composable -private fun CustomerCenterLoaded(state: CustomerCenterState.Success) { - // CustomerCenter WIP: Add proper success UI - Column { - Text("Customer Center config:", fontWeight = FontWeight.Bold, fontSize = 20.sp) - Text(state.customerCenterConfigDataString) +private fun CustomerCenterLoaded( + state: CustomerCenterState.Success, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, +) { + val configuration = state.customerCenterConfigData + if (state.purchaseInformation != null) { + configuration.getManagementScreen()?.let { managementScreen -> + ManageSubscriptionsView( + screen = managementScreen, + purchaseInformation = state.purchaseInformation, + onDetermineFlow = onDetermineFlow, + ) + } ?: run { + // Handle missing management screen + // WrongPlatformView + } + } else { + configuration.getNoActiveScreen()?.let { noActiveScreen -> + ManageSubscriptionsView( + screen = noActiveScreen, + onDetermineFlow = onDetermineFlow, + ) + } ?: run { + // Fallback with a restore button + // NoSubscriptionsView(configuration = configuration) + } } } @@ -98,8 +134,11 @@ internal fun getCustomerCenterViewModel( @Composable internal fun CustomerCenterLoadingPreview() { InternalCustomerCenter( - modifier = Modifier.fillMaxSize().padding(10.dp), state = CustomerCenterState.Loading, + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + onDetermineFlow = {}, ) } @@ -107,8 +146,11 @@ internal fun CustomerCenterLoadingPreview() { @Composable internal fun CustomerCenterErrorPreview() { InternalCustomerCenter( - modifier = Modifier.fillMaxSize().padding(10.dp), state = CustomerCenterState.Error(PurchasesError(PurchasesErrorCode.UnknownBackendError)), + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + onDetermineFlow = {}, ) } @@ -117,8 +159,14 @@ internal fun CustomerCenterErrorPreview() { @Composable internal fun CustomerCenterLoadedPreview() { InternalCustomerCenter( - modifier = Modifier.fillMaxSize().padding(10.dp), - state = CustomerCenterState.Success(previewConfigData.toString()), + state = CustomerCenterState.Success( + customerCenterConfigData = previewConfigData, + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing, + ), + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + onDetermineFlow = {}, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/SubscriptionDetailsView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/SubscriptionDetailsView.kt index 8b02cf45ab..85d4dea947 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/SubscriptionDetailsView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/SubscriptionDetailsView.kt @@ -22,14 +22,14 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.SubscriptionInformation +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PurchaseInformation import com.revenuecat.purchases.ui.revenuecatui.icons.CalendarMonth import com.revenuecat.purchases.ui.revenuecatui.icons.CurrencyExchange import com.revenuecat.purchases.ui.revenuecatui.icons.UniversalCurrencyAlt @Composable internal fun SubscriptionDetailsView( - details: SubscriptionInformation, + details: PurchaseInformation, modifier: Modifier = Modifier, ) { Surface( @@ -139,9 +139,9 @@ private val PaddingHorizontal = 8.dp private val PaddingVertical = 8.dp private const val SizeIconDp = 22 -private class SubscriptionInformationProvider : PreviewParameterProvider { - override val values: Sequence = sequenceOf( - SubscriptionInformation( +private class SubscriptionInformationProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + PurchaseInformation( title = "Basic", durationTitle = "Monthly", price = "$4.99", @@ -149,7 +149,7 @@ private class SubscriptionInformationProvider : PreviewParameterProvider -} - -@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) -internal class CustomerCenterViewModelImpl( - private val purchases: PurchasesType, -) : ViewModel(), CustomerCenterViewModel { - companion object { - private const val STOP_FLOW_TIMEOUT = 5_000L - } - - // This won't load the state until there is a subscriber - override val state = flow { - try { - val customerCenterConfigData = purchases.awaitCustomerCenterConfigData() - emit(CustomerCenterState.Success(customerCenterConfigData.toString())) - } catch (e: PurchasesException) { - emit(CustomerCenterState.Error(e.error)) - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(STOP_FLOW_TIMEOUT), - initialValue = CustomerCenterState.Loading, - ) -} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/SubscriptionInformation.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt similarity index 84% rename from ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/SubscriptionInformation.kt rename to ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt index 44710a8fce..54a63af324 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/SubscriptionInformation.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt @@ -1,6 +1,6 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.data -internal data class SubscriptionInformation( +internal data class PurchaseInformation( val title: String, val durationTitle: String, val price: String, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt new file mode 100644 index 0000000000..5a627f4455 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt @@ -0,0 +1,63 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.revenuecat.purchases.CacheFetchPolicy +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterConfigTestData +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterState +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PurchaseInformation +import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +internal interface CustomerCenterViewModel { + val state: StateFlow + suspend fun determineFlow(path: CustomerCenterConfigData.HelpPath) +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +internal class CustomerCenterViewModelImpl( + private val purchases: PurchasesType, +) : ViewModel(), CustomerCenterViewModel { + companion object { + private const val STOP_FLOW_TIMEOUT = 5_000L + } + + override val state = flow { + try { + val customerCenterConfigData = purchases.awaitCustomerCenterConfigData() + val purchaseInformation = loadPurchaseInformation() + emit(CustomerCenterState.Success(customerCenterConfigData, purchaseInformation)) + } catch (e: PurchasesException) { + emit(CustomerCenterState.Error(e.error)) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_FLOW_TIMEOUT), + initialValue = CustomerCenterState.Loading, + ) + + override suspend fun determineFlow(path: CustomerCenterConfigData.HelpPath) { + if (path.type == CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE) { + purchases.awaitRestore() + } + } + + private suspend fun loadPurchaseInformation(): PurchaseInformation? { + val customerInfo = purchases.awaitCustomerInfo(fetchPolicy = CacheFetchPolicy.FETCH_CURRENT) + + // Customer Center WIP: update when we have subscription information in CustomerInfo + val activeEntitlement = customerInfo.entitlements.active.isEmpty() + if (activeEntitlement) { + return CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing + } + + return null + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModelFactory.kt similarity index 98% rename from ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelFactory.kt rename to ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModelFactory.kt index 000d9019cf..83748f7dab 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModelFactory.kt @@ -1,4 +1,4 @@ -package com.revenuecat.purchases.ui.revenuecatui.customercenter.data +package com.revenuecat.purchases.ui.revenuecatui.customercenter.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/ManageSubscriptionsView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/ManageSubscriptionsView.kt new file mode 100644 index 0000000000..4c41bdf004 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/ManageSubscriptionsView.kt @@ -0,0 +1,246 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter.views + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.ui.revenuecatui.R +import com.revenuecat.purchases.ui.revenuecatui.customercenter.SubscriptionDetailsView +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterConfigTestData +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PurchaseInformation + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Composable +internal fun ManageSubscriptionsView( + screen: CustomerCenterConfigData.Screen, + modifier: Modifier = Modifier, + purchaseInformation: PurchaseInformation? = null, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + purchaseInformation?.let { purchaseInformation -> + ActiveUserManagementView( + screen = screen, + purchaseInformation = purchaseInformation, + onDetermineFlow = onDetermineFlow, + ) + } ?: NoActiveUserManagementView( + screen = screen, + onDetermineFlow = onDetermineFlow, + ) + } + } +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Composable +private fun ActiveUserManagementView( + screen: CustomerCenterConfigData.Screen, + purchaseInformation: PurchaseInformation, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, +) { + Column { + Text( + text = screen.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + screen.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 24.dp), + ) + } + + LazyColumn { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + ), + ) { + SubscriptionDetailsView(details = purchaseInformation) + } + } + + item { + ManageSubscriptionsButtonsView(screen = screen, onDetermineFlow = onDetermineFlow) + } + } + } +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Composable +private fun NoActiveUserManagementView( + screen: CustomerCenterConfigData.Screen, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, +) { + Column { + CompatibilityContentUnavailableView( + title = screen.title, + drawableResId = R.drawable.warning, + description = screen.subtitle, + ) + + ManageSubscriptionsButtonsView( + screen = screen, + onDetermineFlow = onDetermineFlow, + useOutlinedButton = true, + ) + } +} + +@Composable +fun CompatibilityContentUnavailableView( + title: String, + drawableResId: Int, + description: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = drawableResId), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(bottom = 8.dp), + ) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Preview(showBackground = true, device = "spec:width=412dp,height=915dp", group = "scale = 1", fontScale = 1F) +@Composable +private fun ManageSubscriptionsViewPreview() { + val testData = CustomerCenterConfigTestData.customerCenterData() + val managementScreen = testData.screens[CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT]!! + ManageSubscriptionsView( + screen = managementScreen, + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing, + onDetermineFlow = {}, + ) +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Preview(showBackground = true, device = "spec:width=411dp,height=891dp") +@Composable +private fun NoActiveSubscriptionsViewPreview() { + val testData = CustomerCenterConfigTestData.customerCenterData() + val noActiveScreen = testData.screens[CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE]!! + + ManageSubscriptionsView( + screen = noActiveScreen, + purchaseInformation = null, + onDetermineFlow = {}, + ) +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Composable +private fun ManageSubscriptionsButtonsView( + screen: CustomerCenterConfigData.Screen, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, + useOutlinedButton: Boolean = false, +) { + Column { + screen.paths.forEach { path -> + ManageSubscriptionButton( + path = path, + onDetermineFlow = onDetermineFlow, + useOutlinedButton = useOutlinedButton, + ) + } + } +} + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Composable +private fun ManageSubscriptionButton( + path: CustomerCenterConfigData.HelpPath, + onDetermineFlow: (CustomerCenterConfigData.HelpPath) -> Unit, + useOutlinedButton: Boolean, +) { + val buttonModifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + + val buttonContent: @Composable () -> Unit = { + Text( + text = path.title, + color = if (useOutlinedButton) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary, + ) + } + + if (useOutlinedButton) { + OutlinedButton( + onClick = { onDetermineFlow(path) }, + modifier = buttonModifier, + ) { + buttonContent() + } + } else { + Button( + onClick = { onDetermineFlow(path) }, + modifier = buttonModifier, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + ) { + buttonContent() + } + } +}