From 283363989287bcb8073e2bb73d346c495ffa63c7 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 3 Aug 2026 19:43:54 +0200 Subject: [PATCH] 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. --- .../capod/common/upgrade/ui/UpgradeScreen.kt | 6 +- .../capod/common/upgrade/ui/UpgradeScreen.kt | 31 ++-- .../capod/common/upgrade/ui/UpgradeContent.kt | 135 ++++++++++++++++++ .../upgrade/ui/FossUpgradeScreenTest.kt | 14 ++ .../upgrade/ui/GplayUpgradeScreenTest.kt | 17 +++ 5 files changed, 186 insertions(+), 17 deletions(-) diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt index 93c7bce5..300f0d48 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt @@ -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, diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt index 327551a3..f9a52021 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt index 1acfa609..4571029b 100644 --- a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt +++ b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt @@ -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, diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt index a52093b5..4ee8525c 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt @@ -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 diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt index 5220f1bc..92dde0db 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt @@ -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)