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
new file mode 100644
index 00000000..6f92f89b
--- /dev/null
+++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt
@@ -0,0 +1,334 @@
+package eu.darken.capod.upgrade.ui
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.twotone.ArrowBack
+import androidx.compose.material3.CardColors
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import eu.darken.capod.R
+
+// Test tags for the upgrade screen. Existing values are kept verbatim so the behavioral Compose
+// tests keep pointing at the same nodes across the offercard restructure; new surfaces get new tags.
+object UpgradeScreenTags {
+ const val SUB_BUTTON = "upgrade.sub.button"
+ const val IAP_BUTTON = "upgrade.iap.button"
+ const val RESTORE_BUTTON = "upgrade.restore.button"
+ const val RETRY_BUTTON = "upgrade.retry.button"
+ const val RESTORE_BANNER = "upgrade.restore.banner"
+ const val RESTORE_BANNER_ACTION = "upgrade.restore.banner.action"
+ const val OWNER_HERO = "upgrade.owner.hero"
+ const val OWNER_SUB_CARD = "upgrade.owner.subCard"
+ const val OWNER_IAP_CARD = "upgrade.owner.iapCard"
+ const val OWNER_WARNING = "upgrade.owner.bothOwnedWarning"
+ const val MANAGE_SUB_BUTTON = "upgrade.manageSub.button"
+ const val SWITCH_CARD = "upgrade.switch.card"
+ const val SWITCH_BUTTON = "upgrade.switch.button"
+ const val GRACE_CARD = "upgrade.grace.card"
+ const val GRACE_RESTORE_BUTTON = "upgrade.grace.restore"
+ const val DIALOG_STILL_RENEWING = "upgrade.dialog.stillRenewing"
+ const val DIALOG_CHECK_FAILED = "upgrade.dialog.checkFailed"
+ const val DIALOG_RESTORE_FAILED = "upgrade.dialog.restoreFailed"
+ const val CONTACT_SUPPORT_BUTTON = "upgrade.dialog.contactSupport"
+ const val BENEFITS = "upgrade.benefits"
+ const val OFFERS = "upgrade.offers"
+ const val OFFERS_UNAVAILABLE = "upgrade.offers.unavailable"
+ const val LOADING = "upgrade.loading"
+}
+
+// "CAPod Pro" with the postfix highlighted in the upgraded brand color — the same treatment the
+// dashboard title uses. Split from the composed resource so translations can reorder the words.
+@Composable
+internal fun upgradeScreenTitle(): AnnotatedString {
+ val parts = stringResource(R.string.app_name_pro).split(" ").filter { it.isNotEmpty() }
+ val highlight = colorResource(R.color.brand_tertiary)
+ return buildAnnotatedString {
+ if (parts.size == 2) {
+ append("${parts[0]} ")
+ withStyle(SpanStyle(color = highlight, fontWeight = FontWeight.Bold)) { append(parts[1]) }
+ } else {
+ append(stringResource(R.string.app_name_pro))
+ }
+ }
+}
+
+// The screen shell: a plain surface with the floating back arrow (capod convention — the upgrade
+// TopAppBar was removed in 7f2b6976 to avoid clipping the header graphic) over a centered,
+// width-capped scrolling column. Sections are spaced uniformly; the caller supplies the header.
+@Composable
+internal fun UpgradeScreenContainer(
+ onNavigateUp: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) { paddingValues ->
+ Box(modifier = Modifier.padding(paddingValues)) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ // widthIn BEFORE fillMaxWidth: reversed, fillMaxWidth would pin the min to
+ // the full screen and the 560dp cap would never take effect on wide screens.
+ .widthIn(max = 560.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(top = 48.dp, bottom = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ content = content,
+ )
+ }
+ IconButton(
+ onClick = onNavigateUp,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(4.dp),
+ ) {
+ // Matches capod's app-wide back-button convention (no navigate-up string exists).
+ Icon(
+ imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ }
+ }
+}
+
+// The header graphic in a tinted circle. capod has no mascot; splash_graphic2 stands in for it in
+// both the acquisition header and the owned hero.
+@Composable
+internal fun UpgradeHeader(
+ graphicSize: Dp,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Surface(
+ modifier = Modifier.size(graphicSize + 40.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
+ ) {}
+ Image(
+ painter = painterResource(R.drawable.splash_graphic2),
+ contentDescription = null,
+ modifier = Modifier.size(graphicSize),
+ )
+ }
+ }
+}
+
+@Composable
+internal fun UpgradeSectionCard(
+ title: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ iconTint: Color = Color.Unspecified,
+ colors: CardColors? = null,
+ leading: (@Composable () -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val cardColors = colors ?: CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ )
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = cardColors,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ UpgradeSectionHeader(title = title, icon = icon, iconTint = iconTint, leading = leading)
+ content()
+ }
+ }
+}
+
+@Composable
+internal fun UpgradeSectionHeader(
+ title: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ iconTint: Color = Color.Unspecified,
+ leading: (@Composable () -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (leading != null) {
+ leading()
+ } else {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (iconTint == Color.Unspecified) MaterialTheme.colorScheme.primary else iconTint,
+ )
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+@Composable
+internal fun UpgradeSectionBody(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = modifier.fillMaxWidth(),
+ )
+}
+
+@Composable
+internal fun UpgradeHintText(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = modifier.fillMaxWidth(),
+ )
+}
+
+// Container for the offers block: a raised card that resizes smoothly as offer rows appear/vanish.
+@Composable
+internal fun UpgradeActionCard(
+ modifier: Modifier = Modifier,
+ colors: CardColors? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val cardColors = colors ?: CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ )
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = cardColors,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(18.dp)
+ .animateContentSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ content = content,
+ )
+ }
+}
+
+@Composable
+internal fun UpgradeLoadingBlock(
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 18.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressIndicator()
+ }
+}
+
+// Error-container styled card, used for the "prices couldn't load" fallback.
+@Composable
+internal fun UpgradeInlineStateCard(
+ title: String,
+ body: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit = {},
+) {
+ UpgradeSectionCard(
+ title = title,
+ icon = icon,
+ modifier = modifier,
+ iconTint = MaterialTheme.colorScheme.onErrorContainer,
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ content()
+ }
+}
+
+// Spinner-prefixed button label shared by all busy-capable buttons on this screen.
+@Composable
+internal fun BusyButtonLabel(busy: Boolean, text: String) {
+ if (busy) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(text = text)
+}
diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOffers.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOffers.kt
new file mode 100644
index 00000000..5ff63aa1
--- /dev/null
+++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOffers.kt
@@ -0,0 +1,209 @@
+package eu.darken.capod.upgrade.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.twotone.Message
+import androidx.compose.material.icons.twotone.AutoAwesome
+import androidx.compose.material.icons.twotone.Favorite
+import androidx.compose.material.icons.twotone.Headphones
+import androidx.compose.material.icons.twotone.Palette
+import androidx.compose.material.icons.twotone.PlayCircle
+import androidx.compose.material.icons.twotone.Stars
+import androidx.compose.material.icons.twotone.Tune
+import androidx.compose.material.icons.twotone.Widgets
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import eu.darken.capod.R
+
+// The acquisition offers card: header, offer rows, an "or" divider and footnote — but only when
+// BOTH pricing models actually loaded. capod's subscriptionEnabled/iapEnabled do not encode offer
+// availability (both can be true with a null price), so availability drives conditional rendering
+// here while the enabled flags drive the busy/settled gating.
+@Composable
+internal fun LoadedOffers(
+ state: UpgradeUiState.Loaded,
+ onSubscription: () -> Unit,
+ onSubscriptionTrial: () -> Unit,
+ onIap: () -> Unit,
+) {
+ UpgradeActionCard(modifier = Modifier.testTag(UpgradeScreenTags.OFFERS)) {
+ UpgradeSectionHeader(
+ title = stringResource(R.string.upgrade_screen_offers_title),
+ icon = Icons.TwoTone.Stars,
+ )
+
+ val showBoth = state.subAvailable && state.iapAvailable
+
+ if (state.subAvailable) {
+ val isTrial = state.subscriptionAction == SubscriptionAction.TRIAL
+ UpgradeOfferRow(
+ title = stringResource(R.string.upgrade_screen_subscription_offer_title),
+ price = state.subscriptionPrice,
+ hint = stringResource(
+ if (isTrial) R.string.upgrade_screen_subscription_offer_body
+ else R.string.upgrade_screen_subscription_offer_body_no_trial
+ ),
+ ) {
+ Button(
+ onClick = if (isTrial) onSubscriptionTrial else onSubscription,
+ enabled = state.subscriptionEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.SUB_BUTTON),
+ ) {
+ Text(
+ text = stringResource(
+ if (isTrial) R.string.upgrade_screen_subscription_trial_action
+ else R.string.upgrade_screen_subscription_action
+ ),
+ )
+ }
+ }
+ }
+
+ if (showBoth) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = stringResource(R.string.upgrade_screen_offers_or),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ }
+
+ if (state.iapAvailable) {
+ UpgradeOfferRow(
+ // Acquisition uses the neutral "One-time purchase" title; the switch-flavored
+ // iap_offer_title copy stays owner-only.
+ title = stringResource(R.string.upgrade_screen_owned_iap_title),
+ price = state.iapPrice,
+ hint = stringResource(R.string.upgrade_screen_iap_offer_body),
+ ) {
+ OutlinedButton(
+ onClick = onIap,
+ enabled = state.iapEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.IAP_BUTTON),
+ ) {
+ BusyButtonLabel(
+ busy = state.verificationInProgress,
+ text = stringResource(R.string.upgrade_screen_iap_action),
+ )
+ }
+ }
+ }
+
+ if (showBoth) {
+ UpgradeHintText(text = stringResource(R.string.upgrade_screen_offers_body))
+ }
+ }
+}
+
+// Title and price share one line ("·"-joined: direction-neutral punctuation, not translatable
+// copy), terms follow as body text, then the action.
+@Composable
+internal fun UpgradeOfferRow(
+ title: String,
+ price: String?,
+ modifier: Modifier = Modifier,
+ hint: String? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Text(
+ text = listOfNotNull(title, price).joinToString(" · "),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ hint?.let { UpgradeSectionBody(text = it) }
+ Spacer(modifier = Modifier.height(8.dp))
+ content()
+ }
+}
+
+private data class Benefit(val icon: ImageVector, val textRes: Int)
+
+private val BENEFITS = listOf(
+ Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
+ Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
+ Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
+ Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
+ Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
+ Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
+ Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
+)
+
+// capod-specific icon benefit list (kept over SD Maid's text bullets), wrapped in a section card
+// so it joins the offercard visual pattern.
+@Composable
+internal fun UpgradeBenefitsCard(
+ modifier: Modifier = Modifier,
+) {
+ UpgradeSectionCard(
+ title = stringResource(R.string.upgrade_screen_benefits_title),
+ icon = Icons.TwoTone.AutoAwesome,
+ modifier = modifier.testTag(UpgradeScreenTags.BENEFITS),
+ ) {
+ BENEFITS.forEach { benefit ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ modifier = Modifier.size(28.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = benefit.icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(benefit.textRes),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ }
+ UpgradeHintText(
+ text = stringResource(R.string.upgrade_benefit_disclaimer),
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+}
diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOwnership.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOwnership.kt
new file mode 100644
index 00000000..c11c76c4
--- /dev/null
+++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOwnership.kt
@@ -0,0 +1,225 @@
+package eu.darken.capod.upgrade.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.twotone.Autorenew
+import androidx.compose.material.icons.twotone.Verified
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import eu.darken.capod.R
+
+// Ownership presentation for users who already own Pro. Subscribers without the one-time purchase
+// see the switch offer — LOCKED while the subscription still renews, so buying it can't stack with
+// an upcoming renewal.
+@Composable
+internal fun UpgradeOwnershipContent(
+ state: UpgradeUiState.Loaded,
+ onIap: () -> Unit,
+ onManageSubscription: () -> Unit,
+ onRestore: () -> Unit,
+) {
+ val ownership = state.ownership
+ val subscription = ownership.subscription
+ val restoreEnabled = !state.restoreInProgress && !state.verificationInProgress
+
+ UpgradeOwnedHero(ownership = ownership)
+
+ if (ownership.hasIap) {
+ UpgradeSectionCard(
+ title = stringResource(R.string.upgrade_screen_owned_iap_title),
+ icon = Icons.TwoTone.Verified,
+ modifier = Modifier.testTag(UpgradeScreenTags.OWNER_IAP_CARD),
+ ) {
+ UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_owned_iap_body))
+ }
+ }
+
+ if (subscription != null) {
+ UpgradeSectionCard(
+ title = stringResource(R.string.upgrade_screen_owned_sub_title),
+ icon = Icons.TwoTone.Autorenew,
+ modifier = Modifier.testTag(UpgradeScreenTags.OWNER_SUB_CARD),
+ ) {
+ UpgradeSectionBody(
+ text = stringResource(
+ // No dates: the client can't know expiry/renewal, only intent.
+ if (subscription.isAutoRenewing) R.string.upgrade_screen_owned_sub_renewing_body
+ else R.string.upgrade_screen_owned_sub_not_renewing_body
+ ),
+ )
+ if (subscription.isAutoRenewing && ownership.hasIap) {
+ Text(
+ text = stringResource(R.string.upgrade_screen_owned_both_renewing_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.testTag(UpgradeScreenTags.OWNER_WARNING),
+ )
+ }
+ OutlinedButton(
+ onClick = onManageSubscription,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON),
+ ) {
+ Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action))
+ }
+ }
+ }
+
+ if (subscription != null && !ownership.hasIap) {
+ // The switch path as a visible artifact, not just prose: while the subscription still
+ // renews the offer is shown LOCKED with the unlock condition. `iapEnabled` centralizes
+ // settled/restore/verification/ownership gating; the renewal state adds the lock.
+ val switchUnlocked = !subscription.isAutoRenewing
+ UpgradeActionCard(modifier = Modifier.testTag(UpgradeScreenTags.SWITCH_CARD)) {
+ UpgradeOfferRow(
+ title = stringResource(R.string.upgrade_screen_iap_offer_title),
+ price = state.iapPrice,
+ hint = stringResource(
+ if (switchUnlocked) R.string.upgrade_screen_owned_iap_purchase_note
+ else R.string.upgrade_screen_owned_iap_locked_note
+ ),
+ ) {
+ Button(
+ onClick = onIap,
+ enabled = switchUnlocked && state.iapEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.SWITCH_BUTTON),
+ ) {
+ BusyButtonLabel(
+ busy = state.verificationInProgress,
+ text = stringResource(R.string.upgrade_screen_iap_action),
+ )
+ }
+ }
+ }
+ }
+
+ // Framed as a status re-check; support is offered only by the failed-restore dialog.
+ UpgradeRestoreSection(
+ title = stringResource(R.string.upgrade_screen_restore_status_title),
+ body = stringResource(R.string.upgrade_screen_restore_status_body),
+ onRestore = onRestore,
+ restoreInProgress = state.restoreInProgress,
+ enabled = restoreEnabled,
+ )
+}
+
+// The "you have it" moment: header graphic + congrats in one hero card, with the variant
+// (subscription vs one-time) spelled out. The per-purchase cards below carry details and actions.
+@Composable
+private fun UpgradeOwnedHero(
+ ownership: Ownership,
+ modifier: Modifier = Modifier,
+) {
+ ElevatedCard(
+ modifier = modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.OWNER_HERO),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.splash_graphic2),
+ contentDescription = null,
+ modifier = Modifier.size(56.dp),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = stringResource(R.string.upgrade_screen_owned_hero_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ // The permanent purchase is the meaningful one when both are owned.
+ text = stringResource(
+ if (ownership.hasIap) R.string.upgrade_screen_owned_hero_iap_body
+ else R.string.upgrade_screen_owned_hero_sub_body
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+}
+
+// Shown on the acquisition view while Pro is active purely via the local grace window. Calm
+// reassurance, not a warning. Stage 1 confirms Pro is intact (spinner header); stage 2 (after the
+// episode aged past the threshold) explains and offers restore (static icon + button).
+@Composable
+internal fun UpgradeGraceCard(
+ showDiagnostics: Boolean,
+ onRestore: () -> Unit,
+ modifier: Modifier = Modifier,
+ restoreInProgress: Boolean = false,
+ verificationInProgress: Boolean = false,
+) {
+ UpgradeSectionCard(
+ title = stringResource(R.string.upgrade_screen_grace_title),
+ icon = Icons.TwoTone.Verified,
+ modifier = modifier.testTag(UpgradeScreenTags.GRACE_CARD),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
+ leading = if (showDiagnostics) null else {
+ {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.5.dp,
+ )
+ }
+ },
+ ) {
+ Text(
+ text = stringResource(
+ if (showDiagnostics) R.string.upgrade_screen_grace_body
+ else R.string.upgrade_screen_grace_body_short
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (showDiagnostics) {
+ Button(
+ onClick = onRestore,
+ enabled = !restoreInProgress && !verificationInProgress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON),
+ ) {
+ BusyButtonLabel(
+ busy = restoreInProgress,
+ text = stringResource(R.string.upgrade_screen_restore_purchase_action),
+ )
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..e9028edc
--- /dev/null
+++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt
@@ -0,0 +1,118 @@
+package eu.darken.capod.upgrade.ui
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.twotone.Restore
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import eu.darken.capod.R
+
+// Described restore section, shared by all restore audiences (copy and emphasis differ, wiring
+// doesn't). `enabled` covers the settled/verification gating; `restoreInProgress` only drives the
+// spinner — a button that looks enabled while the ViewModel silently rejects the tap is worse than
+// a disabled one. Deliberately NO contact-support action here: escalation is offered only after a
+// restore came up empty (RestoreFailedDialog), so self-service gets its chance first.
+@Composable
+internal fun UpgradeRestoreSection(
+ title: String,
+ body: String,
+ onRestore: () -> Unit,
+ modifier: Modifier = Modifier,
+ restoreInProgress: Boolean = false,
+ enabled: Boolean = true,
+ emphasized: Boolean = false,
+ restoreTag: String = UpgradeScreenTags.RESTORE_BUTTON,
+) {
+ UpgradeSectionCard(
+ title = title,
+ icon = Icons.TwoTone.Restore,
+ modifier = modifier,
+ colors = if (emphasized) {
+ CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ } else {
+ null
+ },
+ ) {
+ if (emphasized) {
+ // The tinted container brings its own content color; the muted body tone is for
+ // neutral surface cards only.
+ Text(text = body, style = MaterialTheme.typography.bodyMedium)
+ Button(
+ onClick = onRestore,
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(restoreTag),
+ ) {
+ BusyButtonLabel(
+ busy = restoreInProgress,
+ text = stringResource(R.string.upgrade_screen_restore_purchase_action),
+ )
+ }
+ } else {
+ UpgradeSectionBody(text = body)
+ OutlinedButton(
+ onClick = onRestore,
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(restoreTag),
+ ) {
+ BusyButtonLabel(
+ busy = restoreInProgress,
+ text = stringResource(R.string.upgrade_screen_restore_purchase_action),
+ )
+ }
+ }
+ }
+}
+
+// The only contact-support surface on the screen: it leads with the just-happened live Play check
+// (RestoreFailed also fires on timeout, so the copy is hedged), then self-service hints, then the
+// escalation. Dismiss uses the generic cancel action (capod has no dedicated dismiss string).
+@Composable
+internal fun RestoreFailedDialog(
+ onDismiss: () -> Unit,
+ onContactSupport: () -> Unit,
+) {
+ AlertDialog(
+ modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED),
+ onDismissRequest = onDismiss,
+ title = { Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) },
+ text = {
+ Text(
+ text = listOf(
+ stringResource(R.string.upgrade_screen_restore_checked_message),
+ stringResource(R.string.upgrade_screen_restore_multiaccount_hint),
+ stringResource(R.string.upgrade_screen_restore_sync_patience_hint),
+ stringResource(R.string.upgrade_screen_restore_contact_hint),
+ ).joinToString("\n\n")
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onContactSupport,
+ modifier = Modifier.testTag(UpgradeScreenTags.CONTACT_SUPPORT_BUTTON),
+ ) {
+ Text(text = stringResource(R.string.upgrade_screen_contact_support_action))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(R.string.general_cancel_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 38b84c68..86fa8e78 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
@@ -2,43 +2,20 @@ package eu.darken.capod.upgrade.ui
import android.app.Activity
import android.widget.Toast
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.twotone.ArrowBack
-import androidx.compose.material.icons.automirrored.twotone.Message
-import androidx.compose.material.icons.twotone.Favorite
-import androidx.compose.material.icons.twotone.Palette
-import androidx.compose.material.icons.twotone.PlayCircle
-import androidx.compose.material.icons.twotone.Stars
-import androidx.compose.material.icons.twotone.Headphones
-import androidx.compose.material.icons.twotone.Tune
-import androidx.compose.material.icons.twotone.Verified
-import androidx.compose.material.icons.twotone.Widgets
+import androidx.compose.material.icons.twotone.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
-import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
+import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -49,51 +26,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.withStyle
-import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
-object UpgradeScreenTags {
- const val SUB_BUTTON = "upgrade.sub.button"
- const val IAP_BUTTON = "upgrade.iap.button"
- const val RESTORE_BUTTON = "upgrade.restore.button"
- const val RETRY_BUTTON = "upgrade.retry.button"
- const val RESTORE_BANNER = "upgrade.restore.banner"
- const val OWNER_HERO = "upgrade.owner.hero"
- const val OWNER_SUB_CARD = "upgrade.owner.subCard"
- const val OWNER_IAP_CARD = "upgrade.owner.iapCard"
- const val OWNER_WARNING = "upgrade.owner.bothOwnedWarning"
- const val MANAGE_SUB_BUTTON = "upgrade.manageSub.button"
- const val SWITCH_CARD = "upgrade.switch.card"
- const val SWITCH_BUTTON = "upgrade.switch.button"
- const val GRACE_CARD = "upgrade.grace.card"
- const val GRACE_RESTORE_BUTTON = "upgrade.grace.restore"
- const val DIALOG_STILL_RENEWING = "upgrade.dialog.stillRenewing"
- const val DIALOG_CHECK_FAILED = "upgrade.dialog.checkFailed"
- const val DIALOG_RESTORE_FAILED = "upgrade.dialog.restoreFailed"
- const val BENEFITS = "upgrade.benefits"
-}
-
@Composable
fun UpgradeScreenHost(
manage: Boolean = false,
@@ -165,10 +112,245 @@ fun UpgradeScreenHost(
}
if (showRestoreFailedDialog) {
- RestoreFailedDialog(onDismiss = { showRestoreFailedDialog = false })
+ RestoreFailedDialog(
+ onDismiss = { showRestoreFailedDialog = false },
+ onContactSupport = {
+ // Dismiss before navigating so the dialog can't linger if this entry is retained.
+ showRestoreFailedDialog = false
+ vm.onContactSupport()
+ },
+ )
}
}
+@Composable
+fun UpgradeScreen(
+ state: UpgradeUiState,
+ onNavigateUp: () -> Unit,
+ onSubscription: () -> Unit,
+ onSubscriptionTrial: () -> Unit,
+ onIap: () -> Unit,
+ onRestore: () -> Unit,
+ onManageSubscription: () -> Unit,
+ onRetry: () -> Unit = {},
+) {
+ UpgradeScreenContainer(onNavigateUp = onNavigateUp) {
+ UpgradeHeader(graphicSize = 80.dp)
+
+ Text(
+ text = upgradeScreenTitle(),
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ when {
+ state is UpgradeUiState.Loading -> UpgradeLoadingBlock(
+ modifier = Modifier.testTag(UpgradeScreenTags.LOADING),
+ )
+
+ state is UpgradeUiState.Loaded && state.ownership.ownsAnything -> UpgradeOwnershipContent(
+ state = state,
+ onIap = onIap,
+ onManageSubscription = onManageSubscription,
+ onRestore = onRestore,
+ )
+
+ state is UpgradeUiState.Loaded && state.grace != null -> GraceContent(
+ state = state,
+ onSubscription = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
+ onIap = onIap,
+ onRestore = onRestore,
+ onRetry = onRetry,
+ )
+
+ state is UpgradeUiState.Loaded -> AcquisitionContent(
+ state = state,
+ onSubscription = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
+ onIap = onIap,
+ onRestore = onRestore,
+ onRetry = onRetry,
+ )
+ }
+ }
+}
+
+// --- Grace view: Pro is active but Play can't confirm the purchase right now ---
+
+@Composable
+private fun GraceContent(
+ state: UpgradeUiState.Loaded,
+ onSubscription: () -> Unit,
+ onSubscriptionTrial: () -> Unit,
+ onIap: () -> Unit,
+ onRestore: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ val grace = state.grace ?: return
+
+ UpgradeGraceCard(
+ showDiagnostics = grace.showDiagnostics,
+ onRestore = onRestore,
+ restoreInProgress = state.restoreInProgress,
+ verificationInProgress = state.verificationInProgress,
+ )
+
+ // Quiet phase: no offers, no pitch — a Play hiccup usually resolves itself and showing buy
+ // buttons to a paying user is confusing. Diagnostics phase: the offers return so an actually
+ // expired subscriber can switch to the one-time purchase without waiting out the grace window.
+ if (grace.showDiagnostics) {
+ UpgradeOffersBox(
+ state = state,
+ onSubscription = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
+ onIap = onIap,
+ onRetry = onRetry,
+ )
+ }
+}
+
+// --- Acquisition view: the sales pitch ---
+
+@Composable
+private fun AcquisitionContent(
+ state: UpgradeUiState.Loaded,
+ onSubscription: () -> Unit,
+ onSubscriptionTrial: () -> Unit,
+ onIap: () -> Unit,
+ onRestore: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ ElevatedCard(
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(R.string.upgrade_preamble),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+
+ // Returning buyer: prominent, emphasized, and the ONLY restore affordance — a second one below
+ // would make the screen feel uncertain about its own advice.
+ if (state.showRestoreBanner) {
+ UpgradeRestoreSection(
+ title = stringResource(R.string.upgrade_screen_restore_banner_title),
+ body = stringResource(R.string.upgrade_screen_restore_banner_body),
+ onRestore = onRestore,
+ modifier = Modifier.testTag(UpgradeScreenTags.RESTORE_BANNER),
+ restoreInProgress = state.restoreInProgress,
+ enabled = !state.restoreInProgress && !state.verificationInProgress,
+ emphasized = true,
+ restoreTag = UpgradeScreenTags.RESTORE_BANNER_ACTION,
+ )
+ }
+
+ UpgradeBenefitsCard()
+
+ UpgradeOffersBox(
+ state = state,
+ onSubscription = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
+ onIap = onIap,
+ onRetry = onRetry,
+ )
+
+ // Restore is account reconciliation, not an offer — its own described section, after the
+ // offers. Only for plain acquisition: returning buyers get the emphasized section up top.
+ if (!state.showRestoreBanner) {
+ UpgradeRestoreSection(
+ title = stringResource(R.string.upgrade_screen_restore_banner_title),
+ body = stringResource(R.string.upgrade_screen_restore_body),
+ onRestore = onRestore,
+ restoreInProgress = state.restoreInProgress,
+ enabled = !state.restoreInProgress && !state.verificationInProgress,
+ )
+ }
+}
+
+// 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 }
+
+@Composable
+private fun UpgradeOffersBox(
+ state: UpgradeUiState.Loaded,
+ onSubscription: () -> Unit,
+ onSubscriptionTrial: () -> Unit,
+ onIap: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ // Key on the phase but carry the whole state: each content instance must render ITS OWN state
+ // snapshot, or a crossfade would recompose the outgoing card with the incoming state (empty
+ // 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 },
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "upgrade-offers",
+ modifier = Modifier.fillMaxWidth(),
+ ) { animatedState ->
+ if (animatedState.subAvailable || animatedState.iapAvailable) {
+ LoadedOffers(
+ state = animatedState,
+ onSubscription = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
+ onIap = onIap,
+ )
+ } else {
+ NoOffersCard(
+ state = animatedState,
+ onIap = onIap,
+ onRetry = onRetry,
+ )
+ }
+ }
+}
+
+// Cold/slow Play returned no product details. Keep both a fallback purchase action (the billing
+// flow re-queries details on launch, so it can still work) AND a Retry that reloads the offers.
+@Composable
+private fun NoOffersCard(
+ state: UpgradeUiState.Loaded,
+ onIap: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ UpgradeInlineStateCard(
+ title = stringResource(R.string.upgrade_screen_offers_unavailable_title),
+ body = stringResource(R.string.upgrade_screen_offers_unavailable_message),
+ icon = Icons.TwoTone.WarningAmber,
+ modifier = Modifier.testTag(UpgradeScreenTags.OFFERS_UNAVAILABLE),
+ ) {
+ Button(
+ onClick = onIap,
+ enabled = state.iapEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.IAP_BUTTON),
+ ) {
+ Text(text = stringResource(R.string.general_upgrade_action))
+ }
+ OutlinedButton(
+ onClick = onRetry,
+ // Disabled while a query runs so repeated taps can't thrash the query flow.
+ enabled = !state.skuQueryInProgress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(UpgradeScreenTags.RETRY_BUTTON),
+ ) {
+ Text(text = stringResource(R.string.general_retry_action))
+ }
+ }
+}
+
+// --- Dialogs kept on the screen (restore-failed lives in UpgradeRestore.kt) ---
+
@Composable
internal fun StillRenewingDialog(
onManage: () -> Unit,
@@ -212,753 +394,6 @@ internal fun CheckFailedDialog(
)
}
-@Composable
-internal fun RestoreFailedDialog(
- onDismiss: () -> Unit,
-) {
- AlertDialog(
- modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED),
- onDismissRequest = onDismiss,
- title = { Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) },
- text = {
- Text(
- text = listOf(
- stringResource(R.string.upgrade_screen_restore_purchase_message),
- stringResource(R.string.upgrade_screen_restore_troubleshooting_msg),
- stringResource(R.string.upgrade_screen_restore_multiaccount_hint),
- stringResource(R.string.upgrade_screen_restore_webinstall_hint),
- stringResource(R.string.upgrade_screen_restore_sync_patience_hint),
- ).joinToString("\n\n")
- )
- },
- confirmButton = {
- TextButton(onClick = onDismiss) {
- Text(text = stringResource(R.string.general_done_action))
- }
- },
- )
-}
-
-private data class Benefit(val icon: ImageVector, val textRes: Int)
-
-@Composable
-fun UpgradeScreen(
- state: UpgradeUiState,
- onNavigateUp: () -> Unit,
- onSubscription: () -> Unit,
- onSubscriptionTrial: () -> Unit,
- onIap: () -> Unit,
- onRestore: () -> Unit,
- onManageSubscription: () -> Unit,
- onRetry: () -> Unit = {},
-) {
- Scaffold(
- containerColor = MaterialTheme.colorScheme.surface,
- ) { paddingValues ->
- Box(modifier = Modifier.padding(paddingValues)) {
- Column(
- modifier = Modifier
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Spacer(modifier = Modifier.height(48.dp))
-
- Box(contentAlignment = Alignment.Center) {
- Surface(
- modifier = Modifier.size(120.dp),
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
- ) {}
- Image(
- painter = painterResource(R.drawable.splash_graphic2),
- contentDescription = null,
- modifier = Modifier.size(80.dp),
- )
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = buildAnnotatedString {
- append("CAPod ")
- withStyle(SpanStyle(color = colorResource(R.color.brand_tertiary), fontWeight = FontWeight.Bold)) {
- append("Pro")
- }
- },
- style = MaterialTheme.typography.headlineLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
-
- Spacer(modifier = Modifier.height(24.dp))
-
- when {
- state is UpgradeUiState.Loading -> {
- CircularProgressIndicator(modifier = Modifier.padding(16.dp))
- }
-
- state is UpgradeUiState.Loaded && state.ownership.ownsAnything -> OwnerContent(
- state = state,
- onIap = onIap,
- onRestore = onRestore,
- onManageSubscription = onManageSubscription,
- )
-
- state is UpgradeUiState.Loaded && state.grace != null -> GraceContent(
- state = state,
- onSubscription = onSubscription,
- onSubscriptionTrial = onSubscriptionTrial,
- onIap = onIap,
- onRestore = onRestore,
- onRetry = onRetry,
- )
-
- state is UpgradeUiState.Loaded -> AcquisitionContent(
- state = state,
- onSubscription = onSubscription,
- onSubscriptionTrial = onSubscriptionTrial,
- onIap = onIap,
- onRestore = onRestore,
- onRetry = onRetry,
- )
- }
-
- Spacer(modifier = Modifier.height(24.dp))
- }
-
- IconButton(
- onClick = onNavigateUp,
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(4.dp),
- ) {
- Icon(
- imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
- contentDescription = null,
- )
- }
- }
- }
-}
-
-// --- Owner view: status instead of sales pitch ---
-
-@Composable
-private fun OwnerContent(
- state: UpgradeUiState.Loaded,
- onIap: () -> Unit,
- onRestore: () -> Unit,
- onManageSubscription: () -> Unit,
-) {
- val ownership = state.ownership
- val subscription = ownership.subscription
-
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.OWNER_HERO),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- imageVector = Icons.TwoTone.Verified,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_owned_hero_title),
- style = MaterialTheme.typography.titleMedium,
- )
- }
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- // The permanent purchase wins the headline when both are owned.
- text = stringResource(
- if (ownership.hasIap) R.string.upgrade_screen_owned_hero_iap_body
- else R.string.upgrade_screen_owned_hero_sub_body
- ),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- }
-
- if (subscription != null) {
- Spacer(modifier = Modifier.height(12.dp))
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.OWNER_SUB_CARD),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = stringResource(R.string.upgrade_screen_owned_sub_title),
- style = MaterialTheme.typography.titleMedium,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- // No dates: the client can't know the expiry/renewal date, only the intent.
- text = stringResource(
- if (subscription.isAutoRenewing) R.string.upgrade_screen_owned_sub_renewing_body
- else R.string.upgrade_screen_owned_sub_not_renewing_body
- ),
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(modifier = Modifier.height(12.dp))
- OutlinedButton(
- onClick = onManageSubscription,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON),
- ) {
- Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action))
- }
- }
- }
- }
-
- if (ownership.hasIap) {
- Spacer(modifier = Modifier.height(12.dp))
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.OWNER_IAP_CARD),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = stringResource(R.string.upgrade_screen_owned_iap_title),
- style = MaterialTheme.typography.titleMedium,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_owned_iap_body),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- }
- }
-
- if (ownership.hasIap && subscription?.isAutoRenewing == true) {
- Spacer(modifier = Modifier.height(12.dp))
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- ),
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.OWNER_WARNING),
- ) {
- Text(
- text = stringResource(R.string.upgrade_screen_owned_both_renewing_warning),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onErrorContainer,
- modifier = Modifier.padding(16.dp),
- )
- }
- }
-
- if (subscription != null && !ownership.hasIap) {
- Spacer(modifier = Modifier.height(12.dp))
- SwitchOfferCard(state = state, onIap = onIap)
- }
-
- Spacer(modifier = Modifier.height(24.dp))
- RestoreSection(
- restoreInProgress = state.restoreInProgress,
- verificationInProgress = state.verificationInProgress,
- onRestore = onRestore,
- )
-}
-
-// Spinner-prefixed button label shared by all busy-capable buttons on this screen.
-@Composable
-private fun BusyButtonLabel(busy: Boolean, text: String) {
- if (busy) {
- CircularProgressIndicator(
- modifier = Modifier.size(18.dp),
- strokeWidth = 2.dp,
- )
- Spacer(modifier = Modifier.width(8.dp))
- }
- Text(text = text)
-}
-
-@Composable
-private fun SwitchOfferCard(
- state: UpgradeUiState.Loaded,
- onIap: () -> Unit,
-) {
- val subscription = state.ownership.subscription ?: return
- val switchUnlocked = !subscription.isAutoRenewing
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.SWITCH_CARD),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = stringResource(R.string.upgrade_screen_iap_offer_title),
- style = MaterialTheme.typography.titleMedium,
- )
- if (state.iapPrice != null) {
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_iap_action_hint, state.iapPrice),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(
- if (switchUnlocked) R.string.upgrade_screen_owned_iap_purchase_note
- else R.string.upgrade_screen_owned_iap_locked_note
- ),
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(modifier = Modifier.height(12.dp))
- Button(
- onClick = onIap,
- // Deliberately NOT gated on price availability: the pricing query may have failed
- // while the purchase would work (the billing flow re-queries details on launch).
- // The fail-closed SUBS verification happens on tap, in the ViewModel.
- enabled = switchUnlocked && state.settled && !state.verificationInProgress && !state.restoreInProgress,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.SWITCH_BUTTON),
- ) {
- BusyButtonLabel(
- busy = state.verificationInProgress,
- text = stringResource(R.string.upgrade_screen_iap_action),
- )
- }
- }
- }
-}
-
-// --- Grace view: Pro is active but Play can't confirm the purchase right now ---
-
-@Composable
-private fun GraceContent(
- state: UpgradeUiState.Loaded,
- onSubscription: () -> Unit,
- onSubscriptionTrial: () -> Unit,
- onIap: () -> Unit,
- onRestore: () -> Unit,
- onRetry: () -> Unit = {},
-) {
- val grace = state.grace ?: return
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.GRACE_CARD),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (grace.showDiagnostics) {
- Icon(
- imageVector = Icons.TwoTone.Verified,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- )
- } else {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- )
- }
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_grace_title),
- style = MaterialTheme.typography.titleMedium,
- )
- }
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(
- if (grace.showDiagnostics) R.string.upgrade_screen_grace_body
- else R.string.upgrade_screen_grace_body_short
- ),
- style = MaterialTheme.typography.bodyMedium,
- )
- if (grace.showDiagnostics) {
- Spacer(modifier = Modifier.height(12.dp))
- Button(
- onClick = onRestore,
- enabled = !state.restoreInProgress && !state.verificationInProgress,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON),
- ) {
- BusyButtonLabel(
- busy = state.restoreInProgress,
- text = stringResource(R.string.upgrade_screen_restore_purchase_action),
- )
- }
- }
- }
- }
-
- // Quiet phase: no offers, no pitch — a Play hiccup usually resolves itself and showing buy
- // buttons to a paying user is confusing. Diagnostics phase: the offers return, so an actually
- // expired subscriber can switch to the one-time purchase without waiting out the grace window.
- if (grace.showDiagnostics) {
- Spacer(modifier = Modifier.height(24.dp))
- PricingContent(
- state = state,
- onSubscription = onSubscription,
- onSubscriptionTrial = onSubscriptionTrial,
- onIap = onIap,
- onRestore = onRestore,
- onRetry = onRetry,
- showRestore = false,
- )
- }
-}
-
-// --- Acquisition view: the sales pitch ---
-
-@Composable
-private fun AcquisitionContent(
- state: UpgradeUiState.Loaded,
- onSubscription: () -> Unit,
- onSubscriptionTrial: () -> Unit,
- onIap: () -> Unit,
- onRestore: () -> Unit,
- onRetry: () -> Unit = {},
-) {
- val benefits = listOf(
- Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
- Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
-
- Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
- Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
- Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
- Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
- Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
- )
-
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- text = stringResource(R.string.upgrade_preamble),
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(16.dp),
- )
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- if (state.showRestoreBanner) {
- RestoreBanner(
- onRestore = onRestore,
- restoreInProgress = state.restoreInProgress,
- verificationInProgress = state.verificationInProgress,
- )
-
- Spacer(modifier = Modifier.height(24.dp))
- }
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.BENEFITS),
- ) {
- Column(modifier = Modifier.padding(vertical = 4.dp)) {
- benefits.forEach { benefit ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = MaterialTheme.colorScheme.primaryContainer,
- modifier = Modifier.size(28.dp),
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- imageVector = benefit.icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(16.dp),
- )
- }
- }
- Spacer(modifier = Modifier.width(12.dp))
- Text(
- text = stringResource(benefit.textRes),
- style = MaterialTheme.typography.bodyLarge,
- )
- }
- }
- }
- }
-
- Text(
- text = stringResource(R.string.upgrade_benefit_disclaimer),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(top = 8.dp),
- )
-
- Spacer(modifier = Modifier.height(24.dp))
-
- PricingContent(
- state = state,
- onSubscription = onSubscription,
- onSubscriptionTrial = onSubscriptionTrial,
- onIap = onIap,
- onRestore = onRestore,
- onRetry = onRetry,
- )
-}
-
-@Composable
-private fun RestoreBanner(
- onRestore: () -> Unit,
- restoreInProgress: Boolean,
- verificationInProgress: Boolean,
-) {
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.tertiaryContainer,
- ),
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.RESTORE_BANNER),
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = stringResource(R.string.upgrade_screen_restore_banner_title),
- style = MaterialTheme.typography.titleMedium,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_restore_banner_body),
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(modifier = Modifier.height(12.dp))
- Button(
- onClick = onRestore,
- enabled = !restoreInProgress && !verificationInProgress,
- modifier = Modifier.fillMaxWidth(),
- ) {
- BusyButtonLabel(
- busy = restoreInProgress,
- text = stringResource(R.string.upgrade_screen_restore_purchase_action),
- )
- }
- }
- }
-}
-
-@Composable
-private fun RestoreSection(
- restoreInProgress: Boolean,
- verificationInProgress: Boolean,
- onRestore: () -> Unit,
-) {
- Text(
- text = stringResource(R.string.upgrade_screen_restore_status_title),
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.upgrade_screen_restore_status_body),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- )
- Spacer(modifier = Modifier.height(8.dp))
- OutlinedButton(
- onClick = onRestore,
- enabled = !restoreInProgress && !verificationInProgress,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(UpgradeScreenTags.RESTORE_BUTTON),
- ) {
- BusyButtonLabel(
- busy = restoreInProgress,
- text = stringResource(R.string.upgrade_screen_restore_purchase_action),
- )
- }
-}
-
-@Composable
-private fun PricingContent(
- state: UpgradeUiState.Loaded,
- onSubscription: () -> Unit,
- onSubscriptionTrial: () -> Unit,
- onIap: () -> Unit,
- onRestore: () -> Unit,
- onRetry: () -> Unit = {},
- showRestore: Boolean = true,
-) {
- // Subscription button (primary)
- if (state.subAvailable) {
- val subscriptionAction =
- if (state.subscriptionAction == SubscriptionAction.TRIAL) onSubscriptionTrial else onSubscription
-
- val subscriptionLabel = if (state.subscriptionAction == SubscriptionAction.TRIAL) {
- stringResource(R.string.upgrade_screen_subscription_trial_action)
- } else {
- stringResource(R.string.upgrade_screen_subscription_action)
- }
-
- Button(
- onClick = subscriptionAction,
- enabled = state.subscriptionEnabled,
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .testTag(UpgradeScreenTags.SUB_BUTTON),
- shape = RoundedCornerShape(12.dp),
- ) {
- Icon(
- imageVector = Icons.TwoTone.Stars,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = subscriptionLabel,
- style = MaterialTheme.typography.titleMedium,
- )
- }
-
- if (state.subscriptionPrice != null) {
- Text(
- text = stringResource(R.string.upgrade_screen_subscription_action_hint, state.subscriptionPrice),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp),
- )
- }
-
- Spacer(modifier = Modifier.height(12.dp))
- }
-
- // IAP button (secondary)
- if (state.iapAvailable) {
- FilledTonalButton(
- onClick = onIap,
- enabled = state.iapEnabled,
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .testTag(UpgradeScreenTags.IAP_BUTTON),
- shape = RoundedCornerShape(12.dp),
- ) {
- Text(
- text = stringResource(R.string.upgrade_screen_iap_action),
- style = MaterialTheme.typography.titleMedium,
- )
- }
-
- if (state.iapPrice != null) {
- Text(
- text = stringResource(R.string.upgrade_screen_iap_action_hint, state.iapPrice),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp),
- )
- }
- }
-
- // If no details loaded at all, show a simple fallback upgrade button (which re-queries the SKU
- // at purchase time) plus a Retry that reloads the offers — a cold/slow Play store can otherwise
- // leave price and subscription/trial selection unavailable for the whole screen visit.
- if (!state.subAvailable && !state.iapAvailable) {
- Button(
- onClick = onIap,
- enabled = state.iapEnabled,
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .testTag(UpgradeScreenTags.IAP_BUTTON),
- shape = RoundedCornerShape(12.dp),
- ) {
- Icon(
- imageVector = Icons.TwoTone.Stars,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- tint = MaterialTheme.colorScheme.onPrimary,
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.general_upgrade_action),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onPrimary,
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- OutlinedButton(
- onClick = onRetry,
- // Disabled while a query is running so repeated taps can't thrash the query flow —
- // owners/grace users keep this fallback visible price-independently.
- enabled = !state.skuQueryInProgress,
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .testTag(UpgradeScreenTags.RETRY_BUTTON),
- shape = RoundedCornerShape(12.dp),
- ) {
- Text(
- text = stringResource(R.string.general_retry_action),
- style = MaterialTheme.typography.titleMedium,
- )
- }
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Text(
- // Don't promise a free trial when Play didn't return the trial offer (e.g. returning
- // subscribers) — the subscribe button already falls back accordingly.
- text = stringResource(
- if (state.subscriptionAction == SubscriptionAction.TRIAL) R.string.upgrade_screen_options_description
- else R.string.upgrade_screen_options_description_no_trial
- ),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- )
-
- if (showRestore) {
- Spacer(modifier = Modifier.height(16.dp))
-
- OutlinedButton(
- onClick = onRestore,
- enabled = !state.restoreInProgress && !state.verificationInProgress,
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .testTag(UpgradeScreenTags.RESTORE_BUTTON),
- shape = RoundedCornerShape(12.dp),
- ) {
- BusyButtonLabel(
- busy = state.restoreInProgress,
- text = stringResource(R.string.upgrade_screen_restore_purchase_action),
- )
- }
- }
-}
-
// --- Previews ---
private fun previewLoaded(
@@ -1020,22 +455,6 @@ private fun UpgradeScreenOwnerSubRenewingPreview() = PreviewWrapper {
)
}
-@Preview2
-@Composable
-private fun UpgradeScreenOwnerSubNotRenewingPreview() = PreviewWrapper {
- UpgradeScreen(
- state = previewLoaded(
- ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
- ),
- onNavigateUp = {},
- onSubscription = {},
- onSubscriptionTrial = {},
- onIap = {},
- onRestore = {},
- onManageSubscription = {},
- )
-}
-
@Preview2
@Composable
private fun UpgradeScreenOwnerIapPreview() = PreviewWrapper {
@@ -1050,39 +469,6 @@ private fun UpgradeScreenOwnerIapPreview() = PreviewWrapper {
)
}
-@Preview2
-@Composable
-private fun UpgradeScreenOwnerBothRenewingPreview() = PreviewWrapper {
- UpgradeScreen(
- state = previewLoaded(
- ownership = Ownership(
- hasIap = true,
- subscription = SubscriptionOwnership(isAutoRenewing = true),
- ),
- ),
- onNavigateUp = {},
- onSubscription = {},
- onSubscriptionTrial = {},
- onIap = {},
- onRestore = {},
- onManageSubscription = {},
- )
-}
-
-@Preview2
-@Composable
-private fun UpgradeScreenGraceQuietPreview() = PreviewWrapper {
- UpgradeScreen(
- state = previewLoaded(grace = GraceHint(showDiagnostics = false)),
- onNavigateUp = {},
- onSubscription = {},
- onSubscriptionTrial = {},
- onIap = {},
- onRestore = {},
- onManageSubscription = {},
- )
-}
-
@Preview2
@Composable
private fun UpgradeScreenGraceDiagnosticsPreview() = PreviewWrapper {
diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt
index b3c59499..4ddda1a5 100644
--- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt
+++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt
@@ -12,6 +12,7 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
+import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
@@ -383,6 +384,11 @@ class UpgradeViewModel @Inject constructor(
webpageTool.open(PLAY_SUBSCRIPTION_URL)
}
+ fun onContactSupport() {
+ log(TAG, INFO) { "onContactSupport()" }
+ navTo(Nav.Settings.ContactSupport)
+ }
+
fun onResume() {
// Returning from Play (e.g. after cancelling renewal on the Manage page) must reflect the
// new renewal state promptly — the global foreground refresh is throttled to once an
diff --git a/app/src/gplay/res/values/strings.xml b/app/src/gplay/res/values/strings.xml
index 0c9965b1..e16022aa 100644
--- a/app/src/gplay/res/values/strings.xml
+++ b/app/src/gplay/res/values/strings.xml
@@ -49,4 +49,19 @@
Confirming your purchase
Google Play hasn\'t confirmed your purchase yet. Pro is still active — no action needed.
Google Play hasn\'t confirmed your purchase for a while. Pro is still active. Make sure you\'re online and signed in with the Google account used for the purchase, then try restoring.
+
+ Upgrade options
+ or
+ Same features, just different pricing models.
+ Prices unavailable
+ Google Play didn\'t return prices right now. Check your connection and try again in a moment.
+ Yearly subscription
+ Renews yearly after the free trial. Cancel anytime in Google Play.
+ Renews yearly. Cancel anytime in Google Play.
+ One payment, no renewals.
+ Upgrade benefits
+ Restoring asks Google Play to re-check this app\'s purchases for the current account. It doesn\'t start a new purchase.
+ CAPod just checked Google Play, but no Pro purchase could be confirmed for the account Google Play is using for this app.
+ Still stuck? Contact support from the email that made the purchase and include your Google Play order number.
+ Contact support
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 433b094c..b14b00c3 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
@@ -1,6 +1,7 @@
package eu.darken.capod.upgrade.ui
import android.app.Application
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
@@ -16,8 +17,9 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-// Behavioral Compose tests for the upgrade screen states — offer visibility, enabled states,
-// grace stages and dialogs. Runs on the JVM via Robolectric (vintage engine under JUnit5).
+// Behavioral Compose tests for the offercard upgrade screen — offer visibility, enabled states,
+// grace stages, restore surfaces and dialogs. Runs on the JVM via Robolectric (vintage engine
+// under JUnit5).
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -26,23 +28,27 @@ class UpgradeScreenComposeTest {
@get:Rule
val composeRule = createComposeRule()
+ // Mirrors toLoadedState's gating (incl. verification) so enabled-state assertions match the VM.
private fun loaded(
ownership: Ownership = Ownership(),
grace: GraceHint? = null,
showRestoreBanner: Boolean = false,
- settledEnabled: Boolean = true,
+ settled: Boolean = true,
restoreInProgress: Boolean = false,
verificationInProgress: Boolean = false,
+ subscriptionAction: SubscriptionAction = SubscriptionAction.TRIAL,
+ subscriptionPrice: String? = "€3.49",
+ iapPrice: String? = "€6.49",
) = UpgradeUiState.Loaded(
- subscriptionAction = SubscriptionAction.TRIAL,
- subscriptionEnabled = settledEnabled && ownership.subscription == null && !restoreInProgress,
- subscriptionPrice = "€3.49",
- iapEnabled = settledEnabled && !ownership.hasIap && !restoreInProgress,
- iapPrice = "€6.49",
+ subscriptionAction = subscriptionAction,
+ subscriptionEnabled = settled && ownership.subscription == null && !restoreInProgress && !verificationInProgress,
+ subscriptionPrice = subscriptionPrice,
+ iapEnabled = settled && !ownership.hasIap && !restoreInProgress && !verificationInProgress,
+ iapPrice = iapPrice,
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
- settled = settledEnabled,
+ settled = settled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
)
@@ -50,6 +56,7 @@ class UpgradeScreenComposeTest {
private fun setScreen(
state: UpgradeUiState,
onSubscription: () -> Unit = {},
+ onSubscriptionTrial: () -> Unit = {},
onIap: () -> Unit = {},
onRestore: () -> Unit = {},
onManageSubscription: () -> Unit = {},
@@ -60,7 +67,7 @@ class UpgradeScreenComposeTest {
state = state,
onNavigateUp = {},
onSubscription = onSubscription,
- onSubscriptionTrial = onSubscription,
+ onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onManageSubscription = onManageSubscription,
@@ -69,15 +76,13 @@ class UpgradeScreenComposeTest {
}
}
- // No-offer fallback state (cold/slow Play store returned no product details).
- private fun noOffers(skuQueryInProgress: Boolean = false) = UpgradeUiState.Loaded(
+ // --- No-offers fallback ---
+
+ private fun noOffers(skuQueryInProgress: Boolean = false) = loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
- subscriptionEnabled = false,
subscriptionPrice = null,
- iapEnabled = true,
iapPrice = null,
- skuQueryInProgress = skuQueryInProgress,
- )
+ ).copy(skuQueryInProgress = skuQueryInProgress)
@Test
fun `the no-offers fallback shows a Retry that fires the callback`() {
@@ -92,6 +97,19 @@ class UpgradeScreenComposeTest {
retries shouldBe 1
}
+ @Test
+ fun `the no-offers fallback purchase button fires onIap`() {
+ var iapTapped = false
+ setScreen(state = noOffers(), onIap = { iapTapped = true })
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON)
+ .performScrollTo()
+ .assertIsEnabled()
+ .performClick()
+
+ iapTapped shouldBe true
+ }
+
@Test
fun `the Retry button is disabled while a SKU query is running`() {
setScreen(state = noOffers(skuQueryInProgress = true))
@@ -101,6 +119,68 @@ class UpgradeScreenComposeTest {
.assertIsNotEnabled()
}
+ // --- Partial offer availability ---
+
+ @Test
+ fun `subscription-only offers hide the IAP row`() {
+ setScreen(loaded(iapPrice = null))
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsDisplayed()
+ composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).assertDoesNotExist()
+ composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
+ }
+
+ @Test
+ fun `iap-only offers hide the subscription row`() {
+ setScreen(loaded(subscriptionAction = SubscriptionAction.UNAVAILABLE, subscriptionPrice = null))
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsDisplayed()
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
+ composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
+ }
+
+ @Test
+ fun `no offers at all shows the unavailable card`() {
+ setScreen(noOffers())
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).performScrollTo().assertIsDisplayed()
+ composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS).assertDoesNotExist()
+ }
+
+ // --- Offer routing ---
+
+ @Test
+ fun `the trial subscription routes to the trial callback`() {
+ var trial = 0
+ var standard = 0
+ setScreen(
+ loaded(subscriptionAction = SubscriptionAction.TRIAL),
+ onSubscription = { standard++ },
+ onSubscriptionTrial = { trial++ },
+ )
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().performClick()
+
+ trial shouldBe 1
+ standard shouldBe 0
+ }
+
+ @Test
+ fun `the standard subscription routes to the standard callback`() {
+ var trial = 0
+ var standard = 0
+ setScreen(
+ loaded(subscriptionAction = SubscriptionAction.STANDARD),
+ onSubscription = { standard++ },
+ onSubscriptionTrial = { trial++ },
+ )
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().performClick()
+
+ standard shouldBe 1
+ trial shouldBe 0
+ }
+
// --- Owner states ---
@Test
@@ -110,9 +190,9 @@ class UpgradeScreenComposeTest {
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_SUB_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
- // No sales pitch, no acquisition buttons for owners.
+ // No sales pitch, no acquisition offers for owners.
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
- composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
+ composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS).assertDoesNotExist()
}
@Test
@@ -130,6 +210,18 @@ class UpgradeScreenComposeTest {
iapTapped shouldBe true
}
+ @Test
+ fun `the non-renewing switch stays enabled even when the IAP price is missing`() {
+ setScreen(
+ loaded(
+ ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
+ iapPrice = null,
+ ),
+ )
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsEnabled()
+ }
+
@Test
fun `iap owner sees the ownership card but no switch or manage actions`() {
setScreen(loaded(ownership = Ownership(hasIap = true)))
@@ -175,7 +267,7 @@ class UpgradeScreenComposeTest {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
- settledEnabled = false,
+ settled = false,
),
)
@@ -194,6 +286,18 @@ class UpgradeScreenComposeTest {
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
}
+ @Test
+ fun `verification in progress disables the owner restore`() {
+ setScreen(
+ loaded(
+ ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true)),
+ verificationInProgress = true,
+ ),
+ )
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
+ }
+
// --- Grace states ---
@Test
@@ -219,11 +323,20 @@ class UpgradeScreenComposeTest {
// Offers return so an actually-expired subscriber can switch without waiting out grace.
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsDisplayed()
+ // No pitch next to the grace card.
+ composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).performScrollTo().performClick()
restoreTapped shouldBe true
}
+ @Test
+ fun `verification disables the grace restore`() {
+ setScreen(loaded(grace = GraceHint(showDiagnostics = true), verificationInProgress = true))
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
+ }
+
// --- Acquisition states ---
@Test
@@ -237,19 +350,58 @@ class UpgradeScreenComposeTest {
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertDoesNotExist()
}
+ @Test
+ fun `same-phase state changes recompose the offers in place`() {
+ // Guards the AnimatedContent keying: a Loaded→Loaded change that keeps offers available
+ // must update the existing offer buttons, not re-run an enter transition or get stuck on a
+ // stale snapshot.
+ val state = mutableStateOf(loaded(settled = false))
+ composeRule.setContent {
+ UpgradeScreen(
+ state = state.value,
+ onNavigateUp = {},
+ onSubscription = {},
+ onSubscriptionTrial = {},
+ onIap = {},
+ onRestore = {},
+ onManageSubscription = {},
+ onRetry = {},
+ )
+ }
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
+
+ state.value = loaded(settled = true)
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsEnabled()
+ }
+
@Test
fun `purchase buttons are disabled before billing has settled`() {
- setScreen(loaded(settledEnabled = false))
+ setScreen(loaded(settled = false))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
- fun `restore banner appears for returning buyers`() {
- setScreen(loaded(showRestoreBanner = true))
+ fun `returning buyers get exactly one emphasized restore action`() {
+ var restored = false
+ setScreen(loaded(showRestoreBanner = true), onRestore = { restored = true })
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER).performScrollTo().assertIsDisplayed()
+ // The emphasized banner action is the ONLY restore affordance — no ordinary section below.
+ composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).assertDoesNotExist()
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER_ACTION).performScrollTo().performClick()
+ restored shouldBe true
+ }
+
+ @Test
+ fun `verification disables the emphasized returning-buyer restore`() {
+ setScreen(loaded(showRestoreBanner = true, verificationInProgress = true))
+
+ composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER_ACTION).performScrollTo().assertIsNotEnabled()
}
@Test
@@ -288,11 +440,17 @@ class UpgradeScreenComposeTest {
}
@Test
- fun `restore-failed dialog renders the troubleshooting hints`() {
+ fun `restore-failed dialog contact support fires only its own callback`() {
+ var contacted = 0
+ var dismissed = 0
composeRule.setContent {
- RestoreFailedDialog(onDismiss = {})
+ RestoreFailedDialog(onDismiss = { dismissed++ }, onContactSupport = { contacted++ })
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED).assertIsDisplayed()
+ composeRule.onNodeWithTag(UpgradeScreenTags.CONTACT_SUPPORT_BUTTON).performClick()
+
+ contacted shouldBe 1
+ dismissed shouldBe 0
}
}
diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt
index 199b651f..e5602410 100644
--- a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt
+++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt
@@ -3,6 +3,7 @@ package eu.darken.capod.upgrade.ui
import android.app.Activity
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.WebpageTool
+import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
@@ -442,6 +443,20 @@ class UpgradeViewModelTest : BaseTest() {
job.cancel()
}
+ @Test
+ fun `contact support navigates to the contact form`() = runTest2 {
+ val repo = mockRepo()
+ val vm = createVm(repo)
+
+ val navEvent = async { vm.navEvents.first() }
+ vm.onContactSupport()
+ advanceUntilIdle()
+
+ val event = navEvent.await()
+ event.shouldBeInstanceOf()
+ event.destination shouldBe Nav.Settings.ContactSupport
+ }
+
// --- State mapping ---
@Test