mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): Avoid red unavailable flash during billing warm-up
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.
This commit is contained in:
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user