From c072b908762de5d7124410317538a26d9e15cdf7 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 24 Jul 2026 14:15:12 +0200 Subject: [PATCH] feat(upgrade): Adopt SD Maid offercard layout and restore UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../darken/capod/upgrade/ui/UpgradeContent.kt | 334 +++++ .../darken/capod/upgrade/ui/UpgradeOffers.kt | 209 ++++ .../capod/upgrade/ui/UpgradeOwnership.kt | 225 ++++ .../darken/capod/upgrade/ui/UpgradeRestore.kt | 118 ++ .../darken/capod/upgrade/ui/UpgradeScreen.kt | 1100 ++++------------- .../capod/upgrade/ui/UpgradeViewModel.kt | 6 + app/src/gplay/res/values/strings.xml | 15 + .../upgrade/ui/UpgradeScreenComposeTest.kt | 206 ++- .../capod/upgrade/ui/UpgradeViewModelTest.kt | 15 + 9 files changed, 1347 insertions(+), 881 deletions(-) create mode 100644 app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeContent.kt create mode 100644 app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOffers.kt create mode 100644 app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeOwnership.kt create mode 100644 app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeRestore.kt 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