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