Skip to content

Commit 8defaed

Browse files
facumenzellaclaude
andauthored
CC-628: Refresh Customer Center UI after subscription cancellation (#3061)
## Summary Fixes issue where Customer Center UI doesn't update after subscription cancellation. ## Changes - Add lifecycle-aware refresh when activity resumes after being paused (when user returns from manage subscriptions screen) - Add `isRefreshing` state to `CustomerCenterState.Success` to show loading indicator during refresh - Add `refreshCustomerCenter()` method that keeps content visible while refreshing (shows subtle loading indicator instead of full loading screen) - Match iOS behavior: refresh when returning from manage subscriptions screen - Show subtle loading indicator and dim content during refresh (similar to iOS) ## Testing - [ ] Test that Customer Center refreshes when returning from Google Play Store manage subscriptions screen - [ ] Verify that cancelled subscriptions are shown correctly after cancellation - [ ] Verify that loading indicator appears during refresh - [ ] Verify that content is dimmed during refresh Closes CC-628 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c574fe7 commit 8defaed

File tree

3 files changed

+120
-8
lines changed

3 files changed

+120
-8
lines changed

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter
55

66
import androidx.activity.compose.BackHandler
77
import androidx.compose.animation.AnimatedContent
8+
import androidx.compose.animation.core.animateFloatAsState
9+
import androidx.compose.animation.core.tween
810
import androidx.compose.foundation.background
911
import androidx.compose.foundation.isSystemInDarkTheme
1012
import androidx.compose.foundation.layout.Arrangement
1113
import androidx.compose.foundation.layout.Box
1214
import androidx.compose.foundation.layout.Column
1315
import androidx.compose.foundation.layout.fillMaxSize
1416
import androidx.compose.foundation.layout.padding
17+
import androidx.compose.material3.CircularProgressIndicator
1518
import androidx.compose.material3.ColorScheme
1619
import androidx.compose.material3.ExperimentalMaterial3Api
1720
import androidx.compose.material3.Icon
@@ -26,6 +29,7 @@ import androidx.compose.material3.TopAppBar
2629
import androidx.compose.material3.TopAppBarDefaults
2730
import androidx.compose.material3.TopAppBarScrollBehavior
2831
import androidx.compose.runtime.Composable
32+
import androidx.compose.runtime.DisposableEffect
2933
import androidx.compose.runtime.Immutable
3034
import androidx.compose.runtime.LaunchedEffect
3135
import androidx.compose.runtime.getValue
@@ -34,10 +38,14 @@ import androidx.compose.runtime.rememberCoroutineScope
3438
import androidx.compose.runtime.rememberUpdatedState
3539
import androidx.compose.ui.Alignment
3640
import androidx.compose.ui.Modifier
41+
import androidx.compose.ui.graphics.graphicsLayer
3742
import androidx.compose.ui.input.nestedscroll.nestedScroll
3843
import androidx.compose.ui.platform.LocalContext
3944
import androidx.compose.ui.tooling.preview.Preview
4045
import androidx.compose.ui.unit.dp
46+
import androidx.lifecycle.Lifecycle
47+
import androidx.lifecycle.LifecycleEventObserver
48+
import androidx.lifecycle.compose.LocalLifecycleOwner
4149
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4250
import androidx.lifecycle.viewmodel.compose.viewModel
4351
import com.revenuecat.purchases.PurchasesError
@@ -105,6 +113,32 @@ internal fun InternalCustomerCenter(
105113
viewModel.trackImpressionIfNeeded()
106114
}
107115

116+
// Refresh Customer Center data when activity resumes after being backgrounded.
117+
// This matches iOS behavior where we refresh when the manage subscriptions sheet is dismissed.
118+
// When the user opens the manage subscriptions screen (Google Play Store), the activity stops.
119+
// When they return, the activity starts again, and we refresh to show updated subscription status.
120+
// Using ON_STOP/ON_START with isChangingConfigurations check to properly handle configuration changes
121+
// (e.g., rotation) without triggering false refreshes.
122+
val lifecycleOwner = LocalLifecycleOwner.current
123+
val activity = context.getActivity()
124+
DisposableEffect(lifecycleOwner) {
125+
val observer = LifecycleEventObserver { _, event ->
126+
when (event) {
127+
Lifecycle.Event.ON_STOP -> {
128+
viewModel.onActivityStopped(activity?.isChangingConfigurations == true)
129+
}
130+
Lifecycle.Event.ON_START -> {
131+
viewModel.onActivityStarted()
132+
}
133+
else -> {}
134+
}
135+
}
136+
lifecycleOwner.lifecycle.addObserver(observer)
137+
onDispose {
138+
lifecycleOwner.lifecycle.removeObserver(observer)
139+
}
140+
}
141+
108142
BackHandler {
109143
viewModel.onNavigationButtonPressed(context, onDismiss)
110144
}
@@ -388,12 +422,32 @@ private fun CustomerCenterLoaded(
388422
}
389423
}
390424

425+
// Animate opacity when refreshing (similar to iOS)
426+
val contentAlpha by animateFloatAsState(
427+
targetValue = if (state.isRefreshing) 0.5f else 1f,
428+
animationSpec = tween(durationMillis = 300),
429+
label = "refreshAlpha",
430+
)
431+
391432
Box(modifier = Modifier.fillMaxSize()) {
392-
CustomerCenterNavHost(
393-
currentDestination = state.currentDestination,
394-
customerCenterState = state,
395-
onAction = onAction,
396-
)
433+
Box(
434+
modifier = Modifier
435+
.fillMaxSize()
436+
.graphicsLayer { alpha = contentAlpha },
437+
) {
438+
CustomerCenterNavHost(
439+
currentDestination = state.currentDestination,
440+
customerCenterState = state,
441+
onAction = onAction,
442+
)
443+
}
444+
445+
// Show loading indicator when refreshing (similar to iOS)
446+
if (state.isRefreshing) {
447+
CircularProgressIndicator(
448+
modifier = Modifier.align(Alignment.Center),
449+
)
450+
}
397451

398452
SnackbarHost(
399453
hostState = snackbarHostState,

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ internal sealed class CustomerCenterState(
3939
@get:JvmSynthetic override val navigationButtonType: NavigationButtonType = NavigationButtonType.CLOSE,
4040
@get:JvmSynthetic val virtualCurrencies: VirtualCurrencies? = null,
4141
@get:JvmSynthetic val showSupportTicketSuccessSnackbar: Boolean = false,
42+
@get:JvmSynthetic val isRefreshing: Boolean = false,
4243
) : CustomerCenterState(navigationButtonType) {
4344
val currentDestination: CustomerCenterDestination
4445
get() = navigationState.currentDestination

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ internal interface CustomerCenterViewModel {
104104

105105
@InternalRevenueCatAPI
106106
suspend fun loadCustomerCenter()
107+
108+
/**
109+
* Refreshes the Customer Center data while keeping the current Success state visible.
110+
* Shows a subtle loading indicator instead of the full loading screen.
111+
* Used when returning from external screens (e.g., manage subscriptions).
112+
*/
113+
suspend fun refreshCustomerCenter()
114+
107115
fun openURL(
108116
context: Context,
109117
url: String,
@@ -128,6 +136,17 @@ internal interface CustomerCenterViewModel {
128136
fun showCreateSupportTicket()
129137

130138
fun dismissSupportTicketSuccessSnackbar()
139+
140+
/**
141+
* Called when the activity is stopped. Used to track if the user backgrounded the app.
142+
* @param isChangingConfigurations true if the stop is due to a configuration change (e.g., rotation)
143+
*/
144+
fun onActivityStopped(isChangingConfigurations: Boolean)
145+
146+
/**
147+
* Called when the activity is started. Triggers a refresh if the user is returning from background.
148+
*/
149+
fun onActivityStarted()
131150
}
132151

133152
@Stable
@@ -175,6 +194,7 @@ internal class CustomerCenterViewModelImpl(
175194
}
176195

177196
private var impressionCreationData: CustomerCenterImpressionEvent.CreationData? = null
197+
private var wasBackgrounded = false
178198
private val _lastLocaleList = MutableStateFlow(getCurrentLocaleList())
179199
private val _colorScheme = MutableStateFlow(colorScheme)
180200
private val _state = MutableStateFlow<CustomerCenterState>(CustomerCenterState.NotLoaded)
@@ -904,8 +924,20 @@ internal class CustomerCenterViewModelImpl(
904924

905925
@InternalRevenueCatAPI
906926
override suspend fun loadCustomerCenter() {
927+
loadCustomerCenter(isRefresh = false)
928+
}
929+
930+
override suspend fun refreshCustomerCenter() {
931+
loadCustomerCenter(isRefresh = true)
932+
}
933+
934+
private suspend fun loadCustomerCenter(isRefresh: Boolean) {
907935
_state.update { state ->
908-
if (state !is CustomerCenterState.Loading) {
936+
if (isRefresh && state is CustomerCenterState.Success) {
937+
// For refresh, keep Success state but set isRefreshing flag
938+
state.copy(isRefreshing = true)
939+
} else if (state !is CustomerCenterState.Loading) {
940+
// For initial load, show full loading screen
909941
CustomerCenterState.Loading
910942
} else {
911943
state
@@ -937,15 +969,40 @@ internal class CustomerCenterViewModelImpl(
937969
detailScreenPaths = emptyList(), // Will be computed when a purchase is selected
938970
noActiveScreenOffering = noActiveScreenOffering,
939971
virtualCurrencies = virtualCurrencies,
972+
isRefreshing = false,
940973
)
941974
val mainScreenPaths = computeMainScreenPaths(successState)
942975

943976
_state.update {
944977
successState.copy(mainScreenPaths = mainScreenPaths)
945978
}
946979
} catch (e: PurchasesException) {
947-
_state.update {
948-
CustomerCenterState.Error(e.error)
980+
_state.update { currentState ->
981+
if (isRefresh && currentState is CustomerCenterState.Success) {
982+
// On error during refresh, keep the existing state but clear isRefreshing
983+
Logger.e("Error refreshing Customer Center data, keeping existing state", e)
984+
currentState.copy(isRefreshing = false)
985+
} else {
986+
CustomerCenterState.Error(e.error)
987+
}
988+
}
989+
}
990+
}
991+
992+
override fun onActivityStopped(isChangingConfigurations: Boolean) {
993+
if (!isChangingConfigurations) {
994+
wasBackgrounded = true
995+
}
996+
}
997+
998+
override fun onActivityStarted() {
999+
if (wasBackgrounded) {
1000+
wasBackgrounded = false
1001+
val currentState = _state.value
1002+
if (currentState is CustomerCenterState.Success && !currentState.isRefreshing) {
1003+
viewModelScope.launch {
1004+
refreshCustomerCenter()
1005+
}
9491006
}
9501007
}
9511008
}

0 commit comments

Comments
 (0)