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 f9a52021..21539bda 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
@@ -207,6 +207,15 @@ internal fun RestoreInconclusiveDialog(
)
}
+// The acquisition pitch inserts the SAME composed brand the status title uses, postfix colored —
+// one brand rendering for both. Word-order-proof: the brand is spliced into the TRANSLATED pattern,
+// so Android's formatter owns placeholder semantics (numbering, reordering, escaping).
+@Composable
+private fun upgradeAcquisitionTitle(): AnnotatedString = spliceBrandTitle(
+ formatted = stringResource(R.string.upgrade_screen_title_template, BRAND_TITLE_MARKER),
+ brand = upgradeScreenTitle(upgraded = true),
+)
+
@Composable
internal fun UpgradeScreen(
uiState: GplayUpgradeUiState = GplayUpgradeUiState.Loading,
@@ -225,13 +234,14 @@ internal fun UpgradeScreen(
val ownedState = loaded?.takeIf { it.ownership.ownsAnything }
UpgradeScreenScaffold(
- // Grace users are still Pro: they get the status title too — "Get SD Maid SE Pro" on the
- // status screen would contradict the rest of the app, which behaves upgraded. The postfix
- // is highlighted like the dashboard title does it.
+ // Grace users are still Pro: they get the bare status title — "Get CAPod Pro" on the status
+ // screen would contradict the rest of the app, which behaves upgraded. Acquisition wraps
+ // that same brand in the pitch sentence. Either way the postfix is highlighted like the
+ // dashboard title does it.
title = if (ownedState != null || loaded?.grace != null) {
upgradeScreenTitle(upgraded = true)
} else {
- AnnotatedString(stringResource(R.string.upgrade_capod_label))
+ upgradeAcquisitionTitle()
},
onNavigateUp = onNavigateUp,
) { paddingValues ->
diff --git a/app/src/gplay/res/values/strings.xml b/app/src/gplay/res/values/strings.xml
index 34e23f4e..52494098 100644
--- a/app/src/gplay/res/values/strings.xml
+++ b/app/src/gplay/res/values/strings.xml
@@ -10,6 +10,8 @@
Google Play reports that you already own this upgrade, but it couldn\'t be restored. Make sure you are using the Google account you purchased with. Play Store synchronization may take time — try rebooting, clearing the Google Play cache or simply waiting.
Restore purchase
Pro
+
+ Get %1$s
CAPod is developed by a single person. Upgrading unlocks extra features and helps keep the app alive.
Same features, different pricing. The subscription includes a free trial and can be cancelled at any time.
Same features, different pricing. The subscription can be cancelled at any time.
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 4571029b..a40bfcdf 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
@@ -112,6 +112,30 @@ internal fun upgradeScreenTitle(
}
}
+// Marker char for brand-title splicing: formatted into the translated pattern via the normal
+// Android format path (so %1$s vs %s, argument reordering, and %% all behave), then replaced
+// with the styled brand. U+FFFC (object replacement) cannot occur in a real translation.
+internal const val BRAND_TITLE_MARKER = ""
+
+internal fun spliceBrandTitle(formatted: String, brand: AnnotatedString): AnnotatedString = buildAnnotatedString {
+ var rest = formatted
+ var found = false
+ while (true) {
+ val idx = rest.indexOf(BRAND_TITLE_MARKER)
+ if (idx < 0) break
+ found = true
+ append(rest.substring(0, idx))
+ append(brand)
+ rest = rest.substring(idx + BRAND_TITLE_MARKER.length)
+ }
+ append(rest)
+ if (!found) {
+ // Defensive: a translation that lost its placeholder still shows the brand.
+ append(" ")
+ append(brand)
+ }
+}
+
@Composable
internal fun UpgradeScreenScaffold(
@StringRes titleRes: Int,
diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleSpliceTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleSpliceTest.kt
new file mode 100644
index 00000000..6ba5e29a
--- /dev/null
+++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleSpliceTest.kt
@@ -0,0 +1,69 @@
+package eu.darken.capod.common.upgrade.ui
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+/**
+ * The brand is spliced into the already-formatted translation, so the styled postfix has to land on
+ * the right offsets no matter where the pattern put the placeholder.
+ */
+class BrandTitleSpliceTest : BaseTest() {
+
+ private val brandColor = Color.Red
+
+ // "CAPod Pro" with the postfix (6..9) colored, like upgradeScreenTitle(upgraded = true).
+ private val brand: AnnotatedString = buildAnnotatedString {
+ append("CAPod ")
+ pushStyle(SpanStyle(color = brandColor))
+ append("Pro")
+ pop()
+ }
+
+ @Test fun `marker in the middle shifts the styled postfix by the prefix`() {
+ val result = spliceBrandTitle("Get $BRAND_TITLE_MARKER", brand)
+
+ result.text shouldBe "Get CAPod Pro"
+ result.spanStyles.size shouldBe 1
+ result.spanStyles.single().item.color shouldBe brandColor
+ result.spanStyles.single().start shouldBe 10
+ result.spanStyles.single().end shouldBe 13
+ result.text.substring(10, 13) shouldBe "Pro"
+ }
+
+ @Test fun `marker at the start keeps the postfix offsets inside the brand`() {
+ val result = spliceBrandTitle("$BRAND_TITLE_MARKER holen", brand)
+
+ result.text shouldBe "CAPod Pro holen"
+ result.spanStyles.size shouldBe 1
+ result.spanStyles.single().start shouldBe 6
+ result.spanStyles.single().end shouldBe 9
+ result.text.substring(6, 9) shouldBe "Pro"
+ }
+
+ @Test fun `a duplicated marker renders the brand twice`() {
+ val result = spliceBrandTitle("$BRAND_TITLE_MARKER und $BRAND_TITLE_MARKER", brand)
+
+ result.text shouldBe "CAPod Pro und CAPod Pro"
+ result.spanStyles.size shouldBe 2
+ result.spanStyles[0].start shouldBe 6
+ result.spanStyles[0].end shouldBe 9
+ result.spanStyles[1].start shouldBe 20
+ result.spanStyles[1].end shouldBe 23
+ result.text.substring(20, 23) shouldBe "Pro"
+ }
+
+ @Test fun `a translation that lost the placeholder still shows the brand`() {
+ val result = spliceBrandTitle("Get Pro", brand)
+
+ result.text shouldBe "Get Pro CAPod Pro"
+ result.spanStyles.size shouldBe 1
+ result.spanStyles.single().item.color shouldBe brandColor
+ result.spanStyles.single().start shouldBe 14
+ result.spanStyles.single().end shouldBe 17
+ }
+}
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 92dde0db..c0309b28 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
@@ -2,6 +2,8 @@ package eu.darken.capod.common.upgrade.ui
import android.content.Context
import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.getUnclippedBoundsInRoot
@@ -36,6 +38,54 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
private val appNameWithPostfix: String
get() = context.getString(R.string.app_name_pro)
+ // What the acquisition top bar must render: the translated pitch pattern with the composed
+ // brand formatted into it.
+ private val acquisitionTitle: String
+ get() = context.getString(R.string.upgrade_screen_title_template, appNameWithPostfix)
+
+ private fun acquisitionState() = GplayUpgradeUiState.Loaded(
+ subscriptionAction = SubscriptionAction.STANDARD,
+ subscriptionEnabled = true,
+ subscriptionPrice = "$12.99",
+ iapEnabled = true,
+ iapPrice = "$24.99",
+ )
+
+ @Test
+ fun `acquisition titles the screen with the brand inside the pitch sentence`() {
+ composeRule.setUpgradeContent {
+ UpgradeScreen(uiState = acquisitionState())
+ }
+
+ composeRule.onAllNodesWithText(acquisitionTitle).assertCountEquals(1)
+ }
+
+ @Test
+ fun `the acquisition title colors exactly the brand postfix`() {
+ composeRule.setUpgradeContent {
+ UpgradeScreen(uiState = acquisitionState())
+ }
+
+ // The pitch splices in the SAME styled brand the status title uses: the upgraded color must
+ // land on the postfix only, never on the surrounding sentence.
+ val rendered = composeRule.onNodeWithText(acquisitionTitle)
+ .fetchSemanticsNode()
+ .config[SemanticsProperties.Text]
+ .single()
+ // Derived like the production title does it: the postfix is the trailing word of the
+ // composed brand.
+ val postfix = appNameWithPostfix.split(" ")[1]
+
+ rendered.text shouldBe acquisitionTitle
+ rendered.spanStyles.size shouldBe 1
+ val span = rendered.spanStyles.single()
+ span.item.color shouldBe Color(context.getColor(R.color.brand_tertiary))
+ rendered.text.substring(span.start, span.end) shouldBe postfix
+ // Pins the range rather than just its content: only one candidate position exists.
+ rendered.text.indexOf(postfix) shouldBe span.start
+ rendered.text.lastIndexOf(postfix) shouldBe span.start
+ }
+
@Test
fun `loading state shows progress and hides actions`() {
composeRule.setUpgradeContent {