mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(upgrade): Merge upgrade screen mascot and preamble into one hero card
Adds UpgradeHeroCard, which pairs the mascot with the preamble copy inside a single ElevatedCard and stacks them once the copy runs out of room. Used by the FOSS pitch view and the GPLAY acquisition view; grace episodes and the FOSS status views keep their standalone header, which has no preamble to pair with. Screen tests pin the hero's presence and absence per state.
This commit is contained in:
@@ -156,11 +156,7 @@ private fun UpgradePitchContent(
|
||||
UpgradeScreenContent(
|
||||
paddingValues = paddingValues,
|
||||
) {
|
||||
UpgradeHeader(
|
||||
mascotSize = 104.dp,
|
||||
)
|
||||
|
||||
UpgradePreambleCard(
|
||||
UpgradeHeroCard(
|
||||
text = stringResource(R.string.upgrade_foss_preamble),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
|
||||
@@ -244,10 +244,23 @@ internal fun UpgradeScreen(
|
||||
// episode ages into the diagnostics stage, the mascot joins the mood: unimpressed
|
||||
// at Google Play, matching the setup card's "needs your attention" face. The young
|
||||
// episode keeps the happy face — its message is that nothing is wrong.
|
||||
UpgradeHeader(
|
||||
mascotSize = 88.dp,
|
||||
happy = loaded?.grace?.showDiagnostics != true,
|
||||
)
|
||||
if (loaded?.grace != null) {
|
||||
// Grace users never see the preamble (sales copy contradicts "still active"),
|
||||
// so there is nothing to pair the mascot with — it stays a standalone header
|
||||
// above the grace card.
|
||||
UpgradeHeader(
|
||||
mascotSize = 88.dp,
|
||||
happy = loaded.grace.showDiagnostics != true,
|
||||
)
|
||||
} else {
|
||||
UpgradeHeroCard(
|
||||
text = stringResource(R.string.upgrade_preamble),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (ownedState != null) {
|
||||
@@ -296,14 +309,8 @@ private fun UpgradeAcquisitionContent(
|
||||
// blip) shows calm status only, an aged one (likely really gone) adds restore AND the offers,
|
||||
// so an expired subscriber can switch without waiting out the full grace window.
|
||||
if (!inGrace) {
|
||||
UpgradePreambleCard(
|
||||
text = stringResource(R.string.upgrade_preamble),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
)
|
||||
|
||||
// The preamble itself now lives in the hero card at the top of the screen, next to the
|
||||
// mascot — only the sections below it are conditional here.
|
||||
if (uiState is GplayUpgradeUiState.Loaded && uiState.wasPreviouslyPro) {
|
||||
// The targeted returning-buyer nudge: prominent placement and emphasis, and the ONLY
|
||||
// restore affordance on the screen — a second one below would make the screen feel
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -40,6 +41,7 @@ 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.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -49,10 +51,13 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.IconButton
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
internal object UpgradeScreenTags {
|
||||
const val LOADING = "upgrade_loading"
|
||||
@@ -80,6 +85,7 @@ internal object UpgradeScreenTags {
|
||||
const val GPLAY_GRACE = "upgrade_gplay_grace"
|
||||
const val GPLAY_GRACE_SPINNER = "upgrade_gplay_grace_spinner"
|
||||
const val GPLAY_GRACE_RESTORE = "upgrade_gplay_grace_restore"
|
||||
const val HERO = "upgrade_hero"
|
||||
}
|
||||
|
||||
// Composed app title with the flavor postfix highlighted in the upgraded color while Pro is
|
||||
@@ -222,6 +228,135 @@ internal fun UpgradeHeader(
|
||||
}
|
||||
}
|
||||
|
||||
private val HERO_GAP = 16.dp
|
||||
|
||||
// Below this much room for the copy the side-by-side split stops paying for itself: measured on a
|
||||
// 320dp screen at 200% font, the row wrapped the preamble over 10 lines (breaking a word mid-way)
|
||||
// and came out TALLER than stacking, which needs 6. Scaled by fontScale because the squeeze comes
|
||||
// from text size as much as from screen width — at 200% font even a normal-width phone must stack.
|
||||
private val HERO_MIN_TEXT_WIDTH = 150.dp
|
||||
|
||||
// The screen opener: mascot and preamble in one card instead of a floating icon stacked on a
|
||||
// separate text box. Side-by-side keeps the mascot at eye level with the copy it introduces, and
|
||||
// buys back the vertical space the standalone header used to spend above the fold — but only while
|
||||
// the copy still has room to breathe, hence the stacked fallback.
|
||||
@Composable
|
||||
internal fun UpgradeHeroCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
mascotSize: Dp = 88.dp,
|
||||
happy: Boolean = true,
|
||||
colors: CardColors = CardDefaults.elevatedCardColors(),
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(UpgradeScreenTags.HERO),
|
||||
colors = colors,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.padding(end = 8.dp),
|
||||
) {
|
||||
val minTextWidth = HERO_MIN_TEXT_WIDTH * LocalDensity.current.fontScale
|
||||
if (maxWidth - mascotSize - HERO_GAP < minTextWidth) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UpgradeMascot(
|
||||
size = mascotSize,
|
||||
happy = happy,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(HERO_GAP),
|
||||
) {
|
||||
UpgradeMascot(
|
||||
size = mascotSize,
|
||||
happy = happy,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview copy matches the shipped preamble in length: the mascot/text split only reads correctly
|
||||
// if the text wraps like it does in the app.
|
||||
private const val PREVIEW_PREAMBLE =
|
||||
"CAPod is developed by a single person. Upgrading unlocks extra features and helps keep the app alive."
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeHeroCardPreview() {
|
||||
PreviewWrapper {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
UpgradeHeroCard(text = PREVIEW_PREAMBLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview2 only varies light/dark, so it can never reach the stacked branch. These two pin the
|
||||
// thresholds that flip it: a narrow screen, and a normal-width screen at 200% font. 280dp, not
|
||||
// 320dp: at 320dp the text still gets ~160dp once the paddings come off, so the row survives.
|
||||
@Preview(showBackground = true, name = "Compact width", widthDp = 280)
|
||||
@Preview(showBackground = true, name = "Huge font", fontScale = 2f)
|
||||
@Composable
|
||||
private fun UpgradeHeroCardCompactPreview() {
|
||||
PreviewWrapper {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
UpgradeHeroCard(text = PREVIEW_PREAMBLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both flavors tint the hero: FOSS on primaryContainer, GPLAY on secondaryContainer. Neither is
|
||||
// the composable's default, so the default-colored preview above would not catch a contrast
|
||||
// regression on the colors that actually ship.
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UpgradeHeroCardTintedPreview() {
|
||||
PreviewWrapper {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
UpgradeHeroCard(
|
||||
text = PREVIEW_PREAMBLE,
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
UpgradeHeroCard(
|
||||
text = PREVIEW_PREAMBLE,
|
||||
happy = false,
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UpgradePreambleCard(
|
||||
text: String,
|
||||
|
||||
@@ -41,6 +41,11 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_benefit_themes)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_sponsor_subtitle)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(1)
|
||||
// The pitch's mascot lives inside the hero card next to the preamble. Exactly one of each:
|
||||
// the standalone header this view used to have must not survive alongside it.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,6 +77,11 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_preamble)).assertCountEquals(0)
|
||||
// No preamble here, so there is nothing to pair the mascot with: no hero card, and the
|
||||
// standalone header keeps its single cheerful mascot.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,6 +118,10 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_DONATE).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
|
||||
// Status view: standalone header, no hero card.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -46,6 +46,11 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.ACTIONS).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(1)
|
||||
// Preamble and mascot ship together in the hero card outside grace: one hero, one mascot,
|
||||
// so a leftover standalone header next to the hero would fail here.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,6 +163,9 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
.assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrades_gplay_unavailable_error))
|
||||
.assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -428,6 +436,11 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
.assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(appNameWithPostfixedHeroBody(R.string.upgrade_screen_owned_hero_sub_body))
|
||||
.assertCountEquals(0)
|
||||
// Owners get their mascot from the congrats hero only — the acquisition hero card and its
|
||||
// preamble must stay away entirely.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -467,6 +480,9 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_SPINNER).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
|
||||
// No preamble during grace, so the mascot has nothing to pair with: standalone header, no
|
||||
// hero card.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_RESTORE).assertCountEquals(0)
|
||||
// The grace card owns restore via its two-stage disclosure — the generic restore section
|
||||
// must not undercut the calm quiet stage with its own restore CTA.
|
||||
@@ -518,6 +534,7 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_SPINNER).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.HERO).assertCountEquals(0)
|
||||
// The aged episode is treated as likely-permanent: the offers come back so an expired
|
||||
// subscriber can switch without waiting out the full grace window. Still no sales pitch.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(1)
|
||||
|
||||
Reference in New Issue
Block a user