Skip to content

Commit

Permalink
[CustomerCenter] Create CustomerCenter composable and view model wi…
Browse files Browse the repository at this point in the history
…th some initial UI (#1867)

### Description
This adds an initial composable and view model with some previews so we
can start iterating over the customer center.
New composable using it as a bottom sheet and using it from paywall
tester will come in follow up PRs.

Nothing exposed in this PR yet.
  • Loading branch information
tonidero authored Oct 4, 2024
1 parent 26074e4 commit 9e421bc
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
Expand All @@ -28,12 +32,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.revenuecat.purchases.ui.debugview.DebugRevenueCatBottomSheet
import com.revenuecat.purchases.ui.revenuecatui.ExperimentalPreviewRevenueCatUIPurchasesAPI
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

private const val BOTTOM_SHEET_MAX_HEIGHT_PERCENTAGE = 0.9f

@OptIn(ExperimentalMaterial3Api::class, ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
fun AppInfoScreen(viewModel: AppInfoScreenViewModel = viewModel<AppInfoScreenViewModelImpl>()) {
var isDebugBottomSheetVisible by remember { mutableStateOf(false) }
var isCustomerCenterBottomSheetVisible by remember { mutableStateOf(false) }
var showLogInDialog by remember { mutableStateOf(false) }

Column(
Expand All @@ -52,6 +61,9 @@ fun AppInfoScreen(viewModel: AppInfoScreenViewModel = viewModel<AppInfoScreenVie
Button(onClick = { isDebugBottomSheetVisible = true }) {
Text(text = "Show debug view")
}
Button(onClick = { isCustomerCenterBottomSheetVisible = true }) {
Text(text = "Show customer center")
}
}

if (showLogInDialog) {
Expand All @@ -64,6 +76,19 @@ fun AppInfoScreen(viewModel: AppInfoScreenViewModel = viewModel<AppInfoScreenVie
isVisible = isDebugBottomSheetVisible,
onDismissCallback = { isDebugBottomSheetVisible = false },
)

if (isCustomerCenterBottomSheetVisible) {
val customerCenterSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { isCustomerCenterBottomSheetVisible = false },
modifier = Modifier.fillMaxHeight(BOTTOM_SHEET_MAX_HEIGHT_PERCENTAGE),
sheetState = customerCenterSheetState,
) {
// CustomerCenter WIP: Uncomment when ready
// CustomerCenter(modifier = Modifier.fillMaxSize())
Text("CustomerCenter disabled. Uncomment when ready.")
}
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.revenuecat.purchases.ui.revenuecatui.customercenter

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.revenuecat.purchases.ui.revenuecatui.ExperimentalPreviewRevenueCatUIPurchasesAPI

/**
* Composable offering a full screen Customer Center UI configured from the RevenueCat dashboard.
*/
@Composable
@ExperimentalPreviewRevenueCatUIPurchasesAPI
// CustomerCenter WIP: Make public when ready
internal fun CustomerCenter(modifier: Modifier = Modifier) {
InternalCustomerCenter(modifier)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.revenuecat.purchases.ui.revenuecatui.customercenter

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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.data.PurchasesImpl
import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType

@Composable
internal fun InternalCustomerCenter(
modifier: Modifier = Modifier,
viewModel: CustomerCenterViewModel = getCustomerCenterViewModel(),
) {
val state by viewModel.state.collectAsState()
InternalCustomerCenter(state, modifier)
}

@Composable
private fun InternalCustomerCenter(
state: CustomerCenterState,
modifier: Modifier = Modifier,
) {
CustomerCenterScaffold(modifier) {
when (state) {
is CustomerCenterState.Loading -> CustomerCenterLoading()
is CustomerCenterState.Error -> CustomerCenterError(state)
is CustomerCenterState.Success -> CustomerCenterLoaded(state)
}
}
}

@Composable
private fun CustomerCenterScaffold(
modifier: Modifier = Modifier,
mainContent: @Composable () -> Unit,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
mainContent()
}
}

@Composable
private fun CustomerCenterLoading() {
// CustomerCenter WIP: Add proper loading UI
Text("Loading...")
}

@Composable
private fun CustomerCenterError(state: CustomerCenterState.Error) {
// CustomerCenter WIP: Add proper error UI
Text("Error: ${state.error}")
}

@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)
}
}

@Composable
internal fun getCustomerCenterViewModel(
purchases: PurchasesType = PurchasesImpl(),
viewModel: CustomerCenterViewModel = viewModel<CustomerCenterViewModelImpl>(
factory = CustomerCenterViewModelFactory(purchases),
),
): CustomerCenterViewModel {
return viewModel
}

@Preview
@Composable
internal fun CustomerCenterLoadingPreview() {
InternalCustomerCenter(
modifier = Modifier.fillMaxSize().padding(10.dp),
state = CustomerCenterState.Loading,
)
}

@Preview
@Composable
internal fun CustomerCenterErrorPreview() {
InternalCustomerCenter(
modifier = Modifier.fillMaxSize().padding(10.dp),
state = CustomerCenterState.Error(PurchasesError(PurchasesErrorCode.UnknownBackendError)),
)
}

@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
@Preview
@Composable
internal fun CustomerCenterLoadedPreview() {
InternalCustomerCenter(
modifier = Modifier.fillMaxSize().padding(10.dp),
state = CustomerCenterState.Success(previewConfigData.toString()),
)
}

@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
private val previewConfigData = CustomerCenterConfigData(
screens = mapOf(
CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT to CustomerCenterConfigData.Screen(
type = CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT,
title = "Manage Subscription",
subtitle = "Manage subscription subtitle",
paths = listOf(
CustomerCenterConfigData.HelpPath(
id = "path-id-1",
title = "Subscription",
type = CustomerCenterConfigData.HelpPath.PathType.CANCEL,
promotionalOffer = null,
feedbackSurvey = null,
),
),
),
),
appearance = CustomerCenterConfigData.Appearance(),
localization = CustomerCenterConfigData.Localization(
locale = "en_US",
localizedStrings = mapOf(
"cancel" to "Cancel",
"subscription" to "Subscription",
),
),
support = CustomerCenterConfigData.Support(email = "test@revenuecat.com"),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.revenuecat.purchases.ui.revenuecatui.customercenter.data

import com.revenuecat.purchases.PurchasesError

internal sealed class CustomerCenterState {
object Loading : CustomerCenterState()
data class Error(val error: PurchasesError) : CustomerCenterState()

// CustomerCenter WIP: Change to use the actual data the customer center will use.
data class Success(val customerCenterConfigDataString: String) : CustomerCenterState()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.revenuecat.purchases.ui.revenuecatui.customercenter.data

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI
import com.revenuecat.purchases.PurchasesException
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

internal interface CustomerCenterViewModel {
val state: StateFlow<CustomerCenterState>
}

@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,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.revenuecat.purchases.ui.revenuecatui.customercenter.data

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType

internal class CustomerCenterViewModelFactory(
private val purchases: PurchasesType,
) : ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CustomerCenterViewModelImpl(purchases) as T
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesAreCompletedBy
import com.revenuecat.purchases.awaitCustomerCenterConfigData
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
import com.revenuecat.purchases.paywalls.events.PaywallEvent

/**
Expand All @@ -29,6 +31,8 @@ internal interface PurchasesType {
fetchPolicy: CacheFetchPolicy = CacheFetchPolicy.default(),
): CustomerInfo

suspend fun awaitCustomerCenterConfigData(): CustomerCenterConfigData

fun track(event: PaywallEvent)

val purchasesAreCompletedBy: PurchasesAreCompletedBy
Expand Down Expand Up @@ -56,6 +60,10 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared
return purchases.awaitCustomerInfo(fetchPolicy)
}

override suspend fun awaitCustomerCenterConfigData(): CustomerCenterConfigData {
return purchases.awaitCustomerCenterConfigData()
}

override fun track(event: PaywallEvent) {
purchases.track(event)
}
Expand Down

0 comments on commit 9e421bc

Please sign in to comment.