fix(upgrade): Color the brand inside the gplay upgrade pitch title

The acquisition top bar reused the widgets' flat upgrade label, so the Pro
postfix stayed uncolored while the owned/grace title highlighted it. The
title is now a gplay-only template that takes the composed brand as a
placeholder, spliced back in as the same styled AnnotatedString the status
title uses — word order stays the translation's business. The widget label
keeps its own key and its own wording.
This commit is contained in:
darken
2026-08-03 20:28:01 +02:00
committed by Matthias Urhahn
parent 2833639892
commit 65f7cbe8b7
5 changed files with 159 additions and 4 deletions
@@ -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 ->
+2
View File
@@ -10,6 +10,8 @@
<string name="upgrades_gplay_already_owned_description">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.</string>
<string name="upgrade_restore_action">Restore purchase</string>
<string name="upgrade_badge_label">Pro</string>
<!-- %1$s is the full localized brand ("CAPod Pro", composed from the app name + postfix) — translate the sentence around it, keep the placeholder. -->
<string name="upgrade_screen_title_template">Get %1$s</string>
<string name="upgrade_preamble">CAPod is developed by a single person. Upgrading unlocks extra features and helps keep the app alive.</string>
<string name="upgrade_screen_options_description">Same features, different pricing. The subscription includes a free trial and can be cancelled at any time.</string>
<string name="upgrade_screen_options_description_no_trial">Same features, different pricing. The subscription can be cancelled at any time.</string>
@@ -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,
@@ -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
}
}
@@ -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 {