feat(upgrade): Adopt SD Maid offercard layout and restore UX

Reshape the Google Play upgrade screen into SD Maid SE's offercard layout:
purchase options as titled offer rows (name · price, terms, action) with an
"or" divider inside one action card, extracted into gplay-local UpgradeContent
/ UpgradeOffers / UpgradeOwnership / UpgradeRestore primitives. Keeps capod's
icon benefits card, splash graphic, and floating back arrow.

Restore now mirrors SD Maid: a reusable restore section (emphasized for
returning buyers), verification-gated across all surfaces, and a restore-failed
dialog that leads with the live Play check and offers Contact support.

Billing logic is unchanged apart from onContactSupport() navigating to the
contact form. Offer rows render conditionally on offer availability; the offers
box AnimatedContent keys on an availability phase so same-state updates
recompose in place.
This commit is contained in:
darken
2026-07-24 14:15:12 +02:00
parent 6175bd3bb1
commit c072b90876
9 changed files with 1347 additions and 881 deletions
@@ -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)
}
@@ -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),
)
}
}
@@ -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),
)
}
}
}
}
@@ -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))
}
},
)
}
File diff suppressed because it is too large Load Diff
@@ -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
+15
View File
@@ -49,4 +49,19 @@
<string name="upgrade_screen_grace_title">Confirming your purchase</string>
<string name="upgrade_screen_grace_body_short">Google Play hasn\'t confirmed your purchase yet. Pro is still active — no action needed.</string>
<string name="upgrade_screen_grace_body">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.</string>
<!-- Offercard layout -->
<string name="upgrade_screen_offers_title">Upgrade options</string>
<string name="upgrade_screen_offers_or">or</string>
<string name="upgrade_screen_offers_body">Same features, just different pricing models.</string>
<string name="upgrade_screen_offers_unavailable_title">Prices unavailable</string>
<string name="upgrade_screen_offers_unavailable_message">Google Play didn\'t return prices right now. Check your connection and try again in a moment.</string>
<string name="upgrade_screen_subscription_offer_title">Yearly subscription</string>
<string name="upgrade_screen_subscription_offer_body">Renews yearly after the free trial. Cancel anytime in Google Play.</string>
<string name="upgrade_screen_subscription_offer_body_no_trial">Renews yearly. Cancel anytime in Google Play.</string>
<string name="upgrade_screen_iap_offer_body">One payment, no renewals.</string>
<string name="upgrade_screen_benefits_title">Upgrade benefits</string>
<string name="upgrade_screen_restore_body">Restoring asks Google Play to re-check this app\'s purchases for the current account. It doesn\'t start a new purchase.</string>
<string name="upgrade_screen_restore_checked_message">CAPod just checked Google Play, but no Pro purchase could be confirmed for the account Google Play is using for this app.</string>
<string name="upgrade_screen_restore_contact_hint">Still stuck? Contact support from the email that made the purchase and include your Google Play order number.</string>
<string name="upgrade_screen_contact_support_action">Contact support</string>
</resources>
@@ -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
}
}
@@ -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<NavEvent.GoTo>()
event.destination shouldBe Nav.Settings.ContactSupport
}
// --- State mapping ---
@Test