mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Merge pull request #643 from d4rken-org/worktree-upgrade-offercard-ui
General: Redesign the Pro upgrade screen and restore flow
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