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