From ac8522d507a476f1c5b7d0236a43e35efd02bf08 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:22:16 +0200 Subject: [PATCH 1/3] fix caret and opening purchase with actions only --- .../customercenter/InternalCustomerCenter.kt | 5 +- .../data/CustomerCenterState.kt | 1 + .../viewmodel/CustomerCenterViewModel.kt | 58 ++++- .../views/PurchaseInformationCardView.kt | 6 +- .../views/RelevantPurchasesListView.kt | 12 +- .../data/CustomerCenterViewModelTests.kt | 212 +++++++++++++++++- .../views/PurchaseInformationCardViewTest.kt | 34 +++ 7 files changed, 302 insertions(+), 26 deletions(-) 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 492537feac..edbeca1304 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 @@ -466,13 +466,14 @@ private fun MainScreenContent( appearance = configuration.appearance, localization = configuration.localization, onPurchaseSelect = { purchase -> - // Only allow selection if there are multiple purchases - if (state.purchases.size > 1) { + // Only allow selection if there are multiple purchases and the purchase has actions + if (state.purchases.size > 1 && purchase in state.purchasesWithActions) { onAction(CustomerCenterAction.SelectPurchase(purchase)) } }, onAction = onAction, purchases = state.purchases, + purchasesWithActions = state.purchasesWithActions, ) } ?: run { // Handle missing management screen 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 index 44f19a5027..36af625421 100644 --- 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 @@ -38,6 +38,7 @@ internal sealed class CustomerCenterState( ), @get:JvmSynthetic override val navigationButtonType: NavigationButtonType = NavigationButtonType.CLOSE, @get:JvmSynthetic val virtualCurrencies: VirtualCurrencies? = null, + @get:JvmSynthetic val purchasesWithActions: Set = emptySet(), ) : CustomerCenterState(navigationButtonType) { val currentDestination: CustomerCenterDestination get() = navigationState.currentDestination 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 index c878491574..ee4a850eff 100644 --- 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 @@ -244,22 +244,24 @@ internal class CustomerCenterViewModelImpl( if (currentState is CustomerCenterState.Success) { val screen = currentState.customerCenterConfigData.getManagementScreen() if (screen != null) { - val baseSupportedPaths = supportedPaths( + val detailSupportedPaths = computeDetailScreenPaths( purchase, screen, currentState.customerCenterConfigData.localization, ) - // For detail screen: only show subscription-specific actions - val detailSupportedPaths = PathUtils.filterSubscriptionSpecificPaths(baseSupportedPaths) - - currentState.copy( - navigationState = currentState.navigationState.push( - CustomerCenterDestination.SelectedPurchaseDetail(purchase, screen.title), - ), - navigationButtonType = CustomerCenterState.NavigationButtonType.BACK, - detailScreenPaths = detailSupportedPaths, - ) + // Only navigate if there are actions available in the detail view + if (detailSupportedPaths.isNotEmpty()) { + currentState.copy( + navigationState = currentState.navigationState.push( + CustomerCenterDestination.SelectedPurchaseDetail(purchase, screen.title), + ), + navigationButtonType = CustomerCenterState.NavigationButtonType.BACK, + detailScreenPaths = detailSupportedPaths, + ) + } else { + currentState + } } else { Logger.e("No management screen available in the customer center config data") CustomerCenterState.Error( @@ -275,6 +277,20 @@ internal class CustomerCenterViewModelImpl( } } + private fun computeDetailScreenPaths( + purchase: PurchaseInformation, + screen: CustomerCenterConfigData.Screen, + localization: CustomerCenterConfigData.Localization, + ): List { + val baseSupportedPaths = supportedPaths( + purchase, + screen, + localization, + ) + // For detail screen: only show subscription-specific actions + return PathUtils.filterSubscriptionSpecificPaths(baseSupportedPaths) + } + override fun onCustomActionSelected(customActionData: CustomActionData) { notifyListenersForCustomActionSelected(customActionData) } @@ -523,6 +539,19 @@ internal class CustomerCenterViewModelImpl( } } + private fun computePurchasesWithActions(state: CustomerCenterState.Success): Set { + val screen = state.customerCenterConfigData.getManagementScreen() ?: return emptySet() + + return state.purchases.filter { purchase -> + val detailPaths = computeDetailScreenPaths( + purchase, + screen, + state.customerCenterConfigData.localization, + ) + detailPaths.isNotEmpty() + }.toSet() + } + private suspend fun loadPurchases( dateFormatter: DateFormatter, locale: Locale, @@ -846,11 +875,16 @@ internal class CustomerCenterViewModelImpl( detailScreenPaths = emptyList(), // Will be computed when a purchase is selected noActiveScreenOffering = noActiveScreenOffering, virtualCurrencies = virtualCurrencies, + purchasesWithActions = emptySet(), // Will be computed below ) val mainScreenPaths = computeMainScreenPaths(successState) + val purchasesWithActions = computePurchasesWithActions(successState) _state.update { - successState.copy(mainScreenPaths = mainScreenPaths) + successState.copy( + mainScreenPaths = mainScreenPaths, + purchasesWithActions = purchasesWithActions, + ) } } catch (e: PurchasesException) { _state.update { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt index 442355e08f..469219ed72 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt @@ -83,16 +83,14 @@ internal fun PurchaseInformationCardView( modifier = Modifier.weight(1f), ) when { - !purchaseInformation.isSubscription && !isDetailedView -> { + onCardClick != null && !isDetailedView -> { Row( horizontalArrangement = Arrangement.spacedBy( CustomerCenterConstants.Card.BADGE_HORIZONTAL_PADDING, ), verticalAlignment = Alignment.CenterVertically, ) { - if (purchaseInformation.isLifetime) { - PurchaseStatusBadge(purchaseInformation, localization) - } + PurchaseStatusBadge(purchaseInformation, localization) Icon( imageVector = KeyboardArrowRight, contentDescription = null, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/RelevantPurchasesListView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/RelevantPurchasesListView.kt index 429933a786..e84f5396d7 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/RelevantPurchasesListView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/RelevantPurchasesListView.kt @@ -37,6 +37,7 @@ internal fun RelevantPurchasesListView( onAction: (CustomerCenterAction) -> Unit, modifier: Modifier = Modifier, purchases: List = emptyList(), + purchasesWithActions: Set = emptySet(), ) { Column( modifier = modifier @@ -52,6 +53,7 @@ internal fun RelevantPurchasesListView( localization = localization, totalPurchaseCount = purchases.size, onPurchaseSelect = onPurchaseSelect, + purchasesWithActions = purchasesWithActions, ) if (nonSubscriptions.isNotEmpty()) { @@ -74,6 +76,7 @@ internal fun RelevantPurchasesListView( localization = localization, totalPurchaseCount = purchases.size, onPurchaseSelect = onPurchaseSelect, + purchasesWithActions = purchasesWithActions, ) } @@ -113,9 +116,10 @@ private fun PurchaseListSection( localization: CustomerCenterConfigData.Localization, totalPurchaseCount: Int, onPurchaseSelect: (PurchaseInformation) -> Unit, + purchasesWithActions: Set, ) { if (purchases.isNotEmpty()) { - purchases.forEachIndexed { index, info -> + purchases.forEachIndexed { index, purchase -> if (index > 0) { Spacer(modifier = Modifier.size(CustomerCenterConstants.Layout.ITEMS_SPACING)) } @@ -127,15 +131,15 @@ private fun PurchaseListSection( else -> ButtonPosition.MIDDLE } PurchaseInformationCardView( - purchaseInformation = info, + purchaseInformation = purchase, localization = localization, modifier = Modifier .fillMaxWidth() .padding(horizontal = CustomerCenterConstants.Layout.HORIZONTAL_PADDING), position = position, isDetailedView = totalPurchaseCount == 1, - onCardClick = if (totalPurchaseCount > 1) { - { onPurchaseSelect(info) } + onCardClick = if (totalPurchaseCount > 1 && purchase in purchasesWithActions) { + { onPurchaseSelect(purchase) } } else { null }, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt index 6c4a317903..c07d5dcc2a 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt @@ -2494,15 +2494,219 @@ class CustomerCenterViewModelTests { coEvery { purchases.awaitCustomerCenterConfigData() } throws PurchasesException( PurchasesError(PurchasesErrorCode.UnknownError, "Test error") ) - + val model = setupViewModel() - + val errorState = model.state.filterIsInstance().first() - + model.showVirtualCurrencyBalances() - + val currentState = model.state.value assertThat(currentState).isEqualTo(errorState) assertThat(currentState).isInstanceOf(CustomerCenterState.Error::class.java) } + + @Test + fun `purchasesWithActions includes only purchases with subscription-specific actions`(): Unit = runBlocking { + setupPurchasesMock() + + val subscription = SubscriptionInfo( + productIdentifier = "monthly_product_id", + purchaseDate = Date(), + originalPurchaseDate = null, + expiresDate = Date(System.currentTimeMillis() + 30L * 24 * 60 * 60 * 1000), // 30 days from now + store = Store.PLAY_STORE, + unsubscribeDetectedAt = null, + isSandbox = false, + billingIssuesDetectedAt = null, + gracePeriodExpiresDate = null, + ownershipType = OwnershipType.PURCHASED, + periodType = PeriodType.NORMAL, + refundedAt = null, + storeTransactionId = null, + requestDate = Date(), + autoResumeDate = null, + displayName = null, + price = null, + productPlanIdentifier = "monthly", + managementURL = Uri.parse("https://play.google.com/store/account/subscriptions"), + ) + + every { customerInfo.subscriptionsByProductIdentifier } returns mapOf("monthly_product_id" to subscription) + every { customerInfo.activeSubscriptions } returns setOf("monthly_product_id") + + val mockProduct = createGoogleStoreProduct( + productId = "monthly_product_id", + basePlanId = "monthly", + name = "Basic" + ) + coEvery { purchases.awaitGetProduct("monthly_product_id", null) } returns mockProduct + + // Screen with only subscription specific paths (CANCEL, REFUND_REQUEST) + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf( + HelpPath( + id = "cancel", + title = "Cancel", + type = HelpPath.PathType.CANCEL, + ), + HelpPath( + id = "refund", + title = "Refund", + type = HelpPath.PathType.REFUND_REQUEST, + ), + ) + ) + + every { configData.getManagementScreen() } returns managementScreen + + val model = setupViewModel() + val state = model.state.filterIsInstance().first() + + assertThat(state.purchasesWithActions).hasSize(1) + assertThat(state.purchasesWithActions.first().isSubscription).isTrue() + assertThat(state.purchasesWithActions).isEqualTo(state.purchases.toSet()) + } + + @Test + fun `purchasesWithActions excludes purchases when no subscription-specific actions available`(): Unit = runBlocking { + setupPurchasesMock() + + // Screen with only general paths (no subscription specific actions) + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf( + HelpPath( + id = "missing", + title = "Missing Purchase", + type = HelpPath.PathType.MISSING_PURCHASE, + ), + HelpPath( + id = "custom", + title = "Custom", + type = HelpPath.PathType.CUSTOM_URL, + url = "https://example.com", + ), + ) + ) + + every { configData.getManagementScreen() } returns managementScreen + + val model = setupViewModel() + val state = model.state.filterIsInstance().first() + + assertThat(state.purchasesWithActions).isEmpty() + } + + @Test + fun `onSelectPurchase does not navigate when purchase has no actions`(): Unit = runBlocking { + setupPurchasesMock() + + // Screen with only general paths (no subscription specific actions) + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf( + HelpPath( + id = "missing", + title = "Missing Purchase", + type = HelpPath.PathType.MISSING_PURCHASE, + ), + ) + ) + + every { configData.getManagementScreen() } returns managementScreen + + val model = setupViewModel() + val initialState = model.state.filterIsInstance().first() + val initialDestination = initialState.currentDestination + + model.selectPurchase(CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing) + + val updatedState = model.state.value as CustomerCenterState.Success + + assertThat(updatedState.currentDestination).isEqualTo(initialDestination) + assertThat(updatedState.currentDestination).isNotInstanceOf(CustomerCenterDestination.SelectedPurchaseDetail::class.java) + } + + @Test + fun `onSelectPurchase navigates to detail screen when purchase has actions`(): Unit = runBlocking { + setupPurchasesMock() + + val subscription = SubscriptionInfo( + productIdentifier = "monthly_product_id", + purchaseDate = Date(), + originalPurchaseDate = null, + expiresDate = Date(System.currentTimeMillis() + 30L * 24 * 60 * 60 * 1000), // 30 days from now + store = Store.PLAY_STORE, + unsubscribeDetectedAt = null, + isSandbox = false, + billingIssuesDetectedAt = null, + gracePeriodExpiresDate = null, + ownershipType = OwnershipType.PURCHASED, + periodType = PeriodType.NORMAL, + refundedAt = null, + storeTransactionId = null, + requestDate = Date(), + autoResumeDate = null, + displayName = null, + price = null, + productPlanIdentifier = "monthly", + managementURL = Uri.parse("https://play.google.com/store/account/subscriptions"), + ) + + every { customerInfo.subscriptionsByProductIdentifier } returns mapOf("monthly_product_id" to subscription) + every { customerInfo.activeSubscriptions } returns setOf("monthly_product_id") + + val mockProduct = createGoogleStoreProduct( + productId = "monthly_product_id", + basePlanId = "monthly", + name = "Basic" + ) + coEvery { purchases.awaitGetProduct("monthly_product_id", null) } returns mockProduct + + // Screen with only subscription specific paths (CANCEL, REFUND_REQUEST) + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf( + HelpPath( + id = "cancel", + title = "Cancel", + type = HelpPath.PathType.CANCEL, + ), + HelpPath( + id = "refund", + title = "Refund", + type = HelpPath.PathType.REFUND_REQUEST, + ), + ) + ) + + every { configData.getManagementScreen() } returns managementScreen + + val model = setupViewModel() + val initialState = model.state.filterIsInstance().first() + + val purchase = initialState.purchases.first() + + // Select a subscription purchase + model.selectPurchase(purchase) + + val updatedState = model.state.value as CustomerCenterState.Success + + assertThat(updatedState.currentDestination).isInstanceOf(CustomerCenterDestination.SelectedPurchaseDetail::class.java) + val detailDestination = updatedState.currentDestination as CustomerCenterDestination.SelectedPurchaseDetail + assertThat(detailDestination.purchaseInformation).isEqualTo(purchase) + + assertThat(updatedState.detailScreenPaths).isNotEmpty() + assertThat(updatedState.detailScreenPaths).hasSizeGreaterThanOrEqualTo(1) + } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt index 5728cbf1fa..3547b6de7a 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt @@ -49,4 +49,38 @@ class PurchaseInformationCardViewTest { } } + @Test + fun `subscription purchase card with onCardClick is clickable`() { + val onCardClick = mockk<() -> Unit>(relaxed = true) + + composeTestRule.setContent { + PurchaseInformationCardView( + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing, + localization = mockLocalization, + isDetailedView = false, + onCardClick = onCardClick + ) + } + + composeTestRule.onNode(hasText("Basic")).performClick() + verify { onCardClick() } + } + + @Test + fun `lifetime purchase card with onCardClick is clickable`() { + val onCardClick = mockk<() -> Unit>(relaxed = true) + + composeTestRule.setContent { + PurchaseInformationCardView( + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationLifetime, + localization = mockLocalization, + isDetailedView = false, + onCardClick = onCardClick + ) + } + + composeTestRule.onAllNodes(hasText("Lifetime"))[0].performClick() + verify { onCardClick() } + } + } \ No newline at end of file From ab3a3620a47e23c1a311b8435372f1536e15fbca Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:28:00 +0200 Subject: [PATCH 2/3] fix wrong test config data --- .../customercenter/data/CustomerCenterConfigTestData.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt index f5309aa346..0149bca3a6 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt @@ -238,7 +238,7 @@ internal object CustomerCenterConfigTestData { isExpired = false, isTrial = false, isCancelled = true, - isLifetime = false, + isLifetime = true, ) val fourVirtualCurrencies = VirtualCurrencies( From f76ad5702d044e43d2f2abf18c025c3ae06cd03d Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:24:29 +0100 Subject: [PATCH 3/3] change computePurchasesWithActions parameters --- .../viewmodel/CustomerCenterViewModel.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 index 13ebc0c15a..58852df76b 100644 --- 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 @@ -650,14 +650,17 @@ internal class CustomerCenterViewModelImpl( } } - private fun computePurchasesWithActions(state: CustomerCenterState.Success): Set { - val screen = state.customerCenterConfigData.getManagementScreen() ?: return emptySet() + private fun computePurchasesWithActions( + purchases: List, + customerCenterConfigData: CustomerCenterConfigData, + ): Set { + val screen = customerCenterConfigData.getManagementScreen() ?: return emptySet() - return state.purchases.filter { purchase -> + return purchases.filter { purchase -> val detailPaths = computeDetailScreenPaths( purchase, screen, - state.customerCenterConfigData.localization, + customerCenterConfigData.localization, ) detailPaths.isNotEmpty() }.toSet() @@ -998,7 +1001,10 @@ internal class CustomerCenterViewModelImpl( isRefreshing = false, ) val mainScreenPaths = computeMainScreenPaths(successState) - val purchasesWithActions = computePurchasesWithActions(successState) + val purchasesWithActions = computePurchasesWithActions( + purchaseInformationList, + customerCenterConfigData, + ) _state.update { successState.copy(