From c822569a6304c8ecfdb6f9c0609206a06d7137f4 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 24 Jul 2026 14:42:58 +0200 Subject: [PATCH] fix(upgrade): Avoid red unavailable flash during billing warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On entry upgradeInfo looks like a non-owner until Play reconciles, so if the SKU query resolves first the offers box briefly rendered the red "unavailable" card before the owner/grace status or prices appeared. Gate that card behind settled and !skuQueryInProgress and show a neutral spinner during warm-up. Also label the restore-failed dialog's dismiss button "Close" instead of "Cancel" — it reports a result, it doesn't ask to abort an action. --- .../darken/capod/upgrade/ui/UpgradeContent.kt | 1 + .../darken/capod/upgrade/ui/UpgradeRestore.kt | 4 ++- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 32 +++++++++++++------ .../upgrade/ui/UpgradeScreenComposeTest.kt | 22 ++++++++++--- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt index 6f92f89b..b152426a 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt @@ -72,6 +72,7 @@ object UpgradeScreenTags { const val BENEFITS = "upgrade.benefits" const val OFFERS = "upgrade.offers" const val OFFERS_UNAVAILABLE = "upgrade.offers.unavailable" + const val OFFERS_SETTLING = "upgrade.offers.settling" const val LOADING = "upgrade.loading" } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt index e9028edc..c20a9b6a 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt @@ -110,8 +110,10 @@ internal fun RestoreFailedDialog( } }, dismissButton = { + // "Close", not "Cancel": this dialog reports a result, it doesn't ask the user to + // confirm or abort an action. TextButton(onClick = onDismiss) { - Text(text = stringResource(R.string.general_cancel_action)) + Text(text = stringResource(R.string.general_close_action)) } }, ) diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 86fa8e78..edbe53ef 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -273,10 +273,20 @@ private fun AcquisitionContent( } } -// The offers card, cross-faded ONLY on the offers-availability discriminator so ordinary -// Loaded→Loaded updates (settled/restore/verification) recompose in place instead of animating a -// duplicate-tagged, briefly-interactive copy of the whole box. -private enum class OffersPhase { LOADED, NO_OFFERS } +// The offers card, cross-faded ONLY on the offers phase so ordinary Loaded→Loaded updates +// (settled/restore/verification) recompose in place instead of animating a duplicate-tagged, +// briefly-interactive copy of the whole box. +private enum class OffersPhase { LOADED, SETTLING, NO_OFFERS } + +private fun UpgradeUiState.Loaded.offersPhase(): OffersPhase = when { + subAvailable || iapAvailable -> OffersPhase.LOADED + // Before the first billing reconciliation (or while a query is still running) missing offers are + // warm-up, not an outage: on entry upgradeInfo looks like a non-owner until Play answers, so an + // owner would otherwise flash the red "unavailable" card for the split second before their + // status resolves. Show a neutral spinner until we're actually sure Play returned nothing. + !settled || skuQueryInProgress -> OffersPhase.SETTLING + else -> OffersPhase.NO_OFFERS +} @Composable private fun UpgradeOffersBox( @@ -291,20 +301,24 @@ private fun UpgradeOffersBox( // offers fading out, etc.). Same-phase Loaded→Loaded updates share a key and recompose in place. AnimatedContent( targetState = state, - contentKey = { if (it.subAvailable || it.iapAvailable) OffersPhase.LOADED else OffersPhase.NO_OFFERS }, + contentKey = { it.offersPhase() }, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "upgrade-offers", modifier = Modifier.fillMaxWidth(), ) { animatedState -> - if (animatedState.subAvailable || animatedState.iapAvailable) { - LoadedOffers( + when (animatedState.offersPhase()) { + OffersPhase.LOADED -> LoadedOffers( state = animatedState, onSubscription = onSubscription, onSubscriptionTrial = onSubscriptionTrial, onIap = onIap, ) - } else { - NoOffersCard( + + OffersPhase.SETTLING -> UpgradeActionCard { + UpgradeLoadingBlock(modifier = Modifier.testTag(UpgradeScreenTags.OFFERS_SETTLING)) + } + + OffersPhase.NO_OFFERS -> NoOffersCard( state = animatedState, onIap = onIap, onRetry = onRetry, diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt index b14b00c3..482f4e82 100644 --- a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt @@ -78,10 +78,11 @@ class UpgradeScreenComposeTest { // --- No-offers fallback --- - private fun noOffers(skuQueryInProgress: Boolean = false) = loaded( + private fun noOffers(skuQueryInProgress: Boolean = false, settled: Boolean = true) = loaded( subscriptionAction = SubscriptionAction.UNAVAILABLE, subscriptionPrice = null, iapPrice = null, + settled = settled, ).copy(skuQueryInProgress = skuQueryInProgress) @Test @@ -111,12 +112,23 @@ class UpgradeScreenComposeTest { } @Test - fun `the Retry button is disabled while a SKU query is running`() { + fun `no offers while a query is running shows the settling spinner, not the unavailable card`() { + // The red "unavailable" card must not appear while offers are still being fetched. setScreen(state = noOffers(skuQueryInProgress = true)) - composeRule.onNodeWithTag(UpgradeScreenTags.RETRY_BUTTON) - .performScrollTo() - .assertIsNotEnabled() + composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_SETTLING).performScrollTo().assertIsDisplayed() + composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist() + composeRule.onNodeWithTag(UpgradeScreenTags.RETRY_BUTTON).assertDoesNotExist() + } + + @Test + fun `no offers before billing has settled shows the settling spinner, not the unavailable card`() { + // On entry the account looks like a non-owner until Play answers; the red card must wait + // until we're actually sure Play returned nothing. + setScreen(state = noOffers(settled = false)) + + composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_SETTLING).performScrollTo().assertIsDisplayed() + composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist() } // --- Partial offer availability ---