refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).
First slice (highest-value, monetization surface):
- PaywallScreen + SubscriptionScreen voice copy (benefit lists, headlines, value props,
"Thank you for supporting Closer", couple-shared taglines) now source from CloserCopy.
- Generic chrome ("Continue", "Restore", "Manage subscription", error-retry) intentionally
stays inline — it isn't brand voice.
- Surfaced real copy drift: the paywall and subscription benefit lists differ (swap two
items + reorder) — co-located and flagged in CloserCopy.kt for a copy decision, left
verbatim (not silently unified).
compileDebugKotlin clean; no behavior change (copy identical, just relocated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
59e033692b
commit
51efff3f78
|
|
@ -224,6 +224,7 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items:
|
||||||
- **Skeleton/loading states over bare spinners (P8).** `LoadingState` exists but many screens still use a bare `CircularProgressIndicator`. Add skeletons for question lists, game histories, home modules, paywall offerings, sync/reveal waits. Acceptance: no primary route shows an isolated spinner on an otherwise blank screen.
|
- **Skeleton/loading states over bare spinners (P8).** `LoadingState` exists but many screens still use a bare `CircularProgressIndicator`. Add skeletons for question lists, game histories, home modules, paywall offerings, sync/reveal waits. Acceptance: no primary route shows an isolated spinner on an otherwise blank screen.
|
||||||
- **Paywall / store value framing (P12).** Paywall has benefits/restore/legal/RevenueCat; needs stronger value framing, real offering/trial clarity, screenshots/previews, and test coverage before release. (Overlaps Pass K/O.)
|
- **Paywall / store value framing (P12).** Paywall has benefits/restore/legal/RevenueCat; needs stronger value framing, real offering/trial clarity, screenshots/previews, and test coverage before release. (Overlaps Pass K/O.)
|
||||||
- **Content metadata & personalization (P13).** The bank is clean; the next leap is *routing*, not more questions — tag mood/depth/relationship-stage/conflict-safe/intimacy-level/time-needed and extend the selection APIs so prompts adapt to skipped topics, relationship length, and recent answers.
|
- **Content metadata & personalization (P13).** The bank is clean; the next leap is *routing*, not more questions — tag mood/depth/relationship-stage/conflict-safe/intimacy-level/time-needed and extend the selection APIs so prompts adapt to skipped topics, relationship length, and recent answers.
|
||||||
|
- **Copy convention + i18n gate.** The app renders copy from hardcoded Compose literals; a half-built `strings.xml` catalog (55 unwired entries) was purged (2026-07-09), and voice copy is being centralized into typed Kotlin catalogs (`ui/brand/CloserBrandCopy.kt` = privacy rotator, `ui/brand/CloserCopy.kt` = product/paywall voice) — one reviewable home, compile-safe, no `stringResource()` friction. Convention: **voice copy → catalog; generic chrome ("Continue"/"Restore") stays inline.** Deliberately NOT localized. **Hard pre-localization gate:** the day translation is on the roadmap, migrate the Kotlin copy catalogs → `res/values/strings.xml` in one pass (retrofitting i18n across hardcoded Compose after launch is far more expensive). First slice done: paywall + subscription; remaining voice copy (onboarding, invite share, age-gate) migrates incrementally as those screens are touched. (Also surfaced: the paywall vs. subscription **benefit lists diverge** — noted in `CloserCopy.kt` for a copy decision.)
|
||||||
|
|
||||||
## Release / pre-ship (consolidated + re-verified 2026-06-28)
|
## Release / pre-ship (consolidated + re-verified 2026-06-28)
|
||||||
- **Real release config before any store submission.** Confirmed still open: `app/build.gradle.kts` `versionCode = 1` / `versionName = "0.1.0"` and `core/navigation/ExternalLinks.kt` legal URLs are placeholder TODOs (`https://closer.app/privacy|terms|subscription-terms`). _Already done:_ a release-blocking Gradle check now fails the build if `RC_API_KEY` is unset/placeholder (`build.gradle.kts:106–110`), so the old "add a gradle guard" sub-item is closed. Remaining: set real version, real legal/support URLs, real RC key + verify offerings/purchase/restore on internal testing. (Tracked by **Pass O** release-readiness.)
|
- **Real release config before any store submission.** Confirmed still open: `app/build.gradle.kts` `versionCode = 1` / `versionName = "0.1.0"` and `core/navigation/ExternalLinks.kt` legal URLs are placeholder TODOs (`https://closer.app/privacy|terms|subscription-terms`). _Already done:_ a release-blocking Gradle check now fails the build if `RC_API_KEY` is unset/placeholder (`build.gradle.kts:106–110`), so the old "add a gradle guard" sub-item is closed. Remaining: set real version, real legal/support URLs, real RC key + verify offerings/purchase/restore on internal testing. (Tracked by **Pass O** release-readiness.)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package app.closer.ui.brand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product / brand-voice copy, centralized so the app's tone lives in one reviewable place.
|
||||||
|
* Sibling to [CloserBrandCopy] (which holds the privacy-rotator lines specifically).
|
||||||
|
*
|
||||||
|
* Scope — VOICE copy only: value propositions, benefit lists, headlines, the lines whose exact
|
||||||
|
* wording is a product decision. Generic UI chrome ("Continue", "Restore", "Manage subscription",
|
||||||
|
* error-retry labels) intentionally stays inline at the call site — it isn't brand voice and
|
||||||
|
* gains nothing from centralization.
|
||||||
|
*
|
||||||
|
* NOT localized — these are Kotlin consts, not string resources. Deliberate for an English-only
|
||||||
|
* pre-launch app: a typed catalog gives one-place review + compile-time safety without the
|
||||||
|
* Compose `stringResource()` friction, and no orphaned-string drift. When localization is on the
|
||||||
|
* roadmap, migrate this catalog to res/values/strings.xml in one pass (tracked in Future.md).
|
||||||
|
*/
|
||||||
|
object CloserCopy {
|
||||||
|
|
||||||
|
/** Reused verbatim across the paywall and the subscription screen. */
|
||||||
|
const val whatsIncluded = "What's included"
|
||||||
|
|
||||||
|
object Paywall {
|
||||||
|
const val headline = "Go deeper together"
|
||||||
|
const val subhead = "Unlock everything Closer has built for couples."
|
||||||
|
const val choosePlanTitle = "Choose your plan"
|
||||||
|
const val coupleShared = "One subscription covers you both — treat your partner."
|
||||||
|
const val thankYouTitle = "You're all set"
|
||||||
|
const val thankYouBody = "Thank you for supporting Closer."
|
||||||
|
|
||||||
|
val benefits = listOf(
|
||||||
|
"Unlimited questions every day",
|
||||||
|
"Every premium question pack",
|
||||||
|
"Date planning and bucket list",
|
||||||
|
"Full answer history and growth",
|
||||||
|
"Custom questions and private notes",
|
||||||
|
"Exportable memories",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Subscription {
|
||||||
|
const val upgradeHeadline = "Unlock Premium"
|
||||||
|
const val premiumTitle = "You're Premium"
|
||||||
|
const val coupleShared = "One subscription for both partners — no double billing."
|
||||||
|
|
||||||
|
// NOTE (copy drift surfaced by centralizing): this benefit list diverges from
|
||||||
|
// [Paywall.benefits] — it swaps "Custom questions and private notes" + "Exportable
|
||||||
|
// memories" for "Connection Challenges and Desire Sync" + "Memory Lane time capsules",
|
||||||
|
// and reorders. Left verbatim on purpose; unify the two lists if that's a copy decision.
|
||||||
|
val benefits = listOf(
|
||||||
|
"Unlimited questions every day",
|
||||||
|
"Every premium question pack",
|
||||||
|
"Full answer history and growth",
|
||||||
|
"Date planning and bucket list",
|
||||||
|
"Connection Challenges and Desire Sync",
|
||||||
|
"Memory Lane time capsules",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,17 +69,9 @@ import app.closer.ui.components.ErrorState
|
||||||
import app.closer.ui.components.LoadingState
|
import app.closer.ui.components.LoadingState
|
||||||
import app.closer.ui.theme.CloserPalette
|
import app.closer.ui.theme.CloserPalette
|
||||||
import com.revenuecat.purchases.Package
|
import com.revenuecat.purchases.Package
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
||||||
private val BENEFITS = listOf(
|
|
||||||
"Unlimited questions every day",
|
|
||||||
"Every premium question pack",
|
|
||||||
"Date planning and bucket list",
|
|
||||||
"Full answer history and growth",
|
|
||||||
"Custom questions and private notes",
|
|
||||||
"Exportable memories"
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PaywallScreen(
|
fun PaywallScreen(
|
||||||
onNavigate: (String) -> Unit = {},
|
onNavigate: (String) -> Unit = {},
|
||||||
|
|
@ -207,13 +199,13 @@ private fun HeaderSection(
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Go deeper together",
|
text = CloserCopy.Paywall.headline,
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Unlock everything Closer has built for couples.",
|
text = CloserCopy.Paywall.subhead,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -243,7 +235,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) {
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "What's included",
|
text = CloserCopy.whatsIncluded,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
@ -252,7 +244,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
BENEFITS.forEach { benefit ->
|
CloserCopy.Paywall.benefits.forEach { benefit ->
|
||||||
BenefitPill(benefit)
|
BenefitPill(benefit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,14 +297,14 @@ private fun PlanOptions(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Choose your plan",
|
text = CloserCopy.Paywall.choosePlanTitle,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
// Couple-shared premium: one purchase unlocks BOTH partners (CouplePremiumChecker).
|
// Couple-shared premium: one purchase unlocks BOTH partners (CouplePremiumChecker).
|
||||||
// Framed as a gift — the real, honest hook, no billing changes needed.
|
// Framed as a gift — the real, honest hook, no billing changes needed.
|
||||||
Text(
|
Text(
|
||||||
text = "One subscription covers you both — treat your partner.",
|
text = CloserCopy.Paywall.coupleShared,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||||
color = Color(0xFF56306F)
|
color = Color(0xFF56306F)
|
||||||
)
|
)
|
||||||
|
|
@ -510,12 +502,12 @@ private fun ThankYouOverlay(onDismiss: () -> Unit) {
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "You're all set",
|
text = CloserCopy.Paywall.thankYouTitle,
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Thank you for supporting Closer.",
|
text = CloserCopy.Paywall.thankYouBody,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
|
|
|
||||||
|
|
@ -67,17 +67,9 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
||||||
private val BENEFITS = listOf(
|
|
||||||
"Unlimited questions every day",
|
|
||||||
"Every premium question pack",
|
|
||||||
"Full answer history and growth",
|
|
||||||
"Date planning and bucket list",
|
|
||||||
"Connection Challenges and Desire Sync",
|
|
||||||
"Memory Lane time capsules",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SubscriptionUiState(
|
data class SubscriptionUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val isPremium: Boolean = false,
|
val isPremium: Boolean = false,
|
||||||
|
|
@ -213,7 +205,7 @@ private fun PremiumContent(
|
||||||
) {
|
) {
|
||||||
SubscriptionHeroImage()
|
SubscriptionHeroImage()
|
||||||
Text(
|
Text(
|
||||||
text = "You're Premium",
|
text = CloserCopy.Subscription.premiumTitle,
|
||||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
@ -238,11 +230,11 @@ private fun PremiumContent(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "What's included",
|
text = CloserCopy.whatsIncluded,
|
||||||
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
BENEFITS.forEach { benefit ->
|
CloserCopy.Subscription.benefits.forEach { benefit ->
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -319,14 +311,14 @@ private fun FreeContent(
|
||||||
SubscriptionHeroImage()
|
SubscriptionHeroImage()
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Unlock Premium",
|
text = CloserCopy.Subscription.upgradeHeadline,
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "One subscription for both partners — no double billing.",
|
text = CloserCopy.Subscription.coupleShared,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
|
|
@ -343,7 +335,7 @@ private fun FreeContent(
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
BENEFITS.forEach { benefit ->
|
CloserCopy.Subscription.benefits.forEach { benefit ->
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue