diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt index 3102e1c5d2..fa9bfa98be 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt @@ -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 @@ -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()) { var isDebugBottomSheetVisible by remember { mutableStateOf(false) } + var isCustomerCenterBottomSheetVisible by remember { mutableStateOf(false) } var showLogInDialog by remember { mutableStateOf(false) } Column( @@ -52,6 +61,9 @@ fun AppInfoScreen(viewModel: AppInfoScreenViewModel = viewModel 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( + 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"), +) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt new file mode 100644 index 0000000000..60bf810bf7 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt @@ -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() +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModel.kt new file mode 100644 index 0000000000..4e59d6594f --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModel.kt @@ -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 +} + +@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/CustomerCenterViewModelFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelFactory.kt new file mode 100644 index 0000000000..000d9019cf --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelFactory.kt @@ -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 create(modelClass: Class): T { + return CustomerCenterViewModelImpl(purchases) as T + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt index 6f819a3683..fa091e856f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt @@ -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 /** @@ -29,6 +31,8 @@ internal interface PurchasesType { fetchPolicy: CacheFetchPolicy = CacheFetchPolicy.default(), ): CustomerInfo + suspend fun awaitCustomerCenterConfigData(): CustomerCenterConfigData + fun track(event: PaywallEvent) val purchasesAreCompletedBy: PurchasesAreCompletedBy @@ -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) }