Merge pull request #644 from d4rken-org/fix/upgrade-offercard-flash

Fix: Prevent red flash when opening the Pro upgrade screen
This commit is contained in:
Matthias Urhahn
2026-07-24 15:15:02 +02:00
committed by GitHub
4 changed files with 44 additions and 15 deletions
@@ -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"
}
@@ -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))
}
},
)
@@ -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,
@@ -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 ---