From 51efff3f7883a9dcbfc17d1108df1883aac5584c Mon Sep 17 00:00:00 2001 From: null Date: Thu, 9 Jul 2026 00:26:15 -0500 Subject: [PATCH] =?UTF-8?q?refactor(copy):=20start=20CloserCopy=20catalog?= =?UTF-8?q?=20=E2=80=94=20migrate=20paywall=20+=20subscription=20voice=20c?= =?UTF-8?q?opy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Future.md | 1 + .../java/app/closer/ui/brand/CloserCopy.kt | 58 +++++++++++++++++++ .../app/closer/ui/paywall/PaywallScreen.kt | 26 +++------ .../closer/ui/settings/SubscriptionScreen.kt | 22 +++---- 4 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/brand/CloserCopy.kt diff --git a/Future.md b/Future.md index d662689d..8e38c7cd 100644 --- a/Future.md +++ b/Future.md @@ -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. - **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. +- **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) - **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.) diff --git a/app/src/main/java/app/closer/ui/brand/CloserCopy.kt b/app/src/main/java/app/closer/ui/brand/CloserCopy.kt new file mode 100644 index 00000000..76bd02d2 --- /dev/null +++ b/app/src/main/java/app/closer/ui/brand/CloserCopy.kt @@ -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", + ) + } +} diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index 18edfcba..8886fccd 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -69,17 +69,9 @@ import app.closer.ui.components.ErrorState import app.closer.ui.components.LoadingState import app.closer.ui.theme.CloserPalette import com.revenuecat.purchases.Package +import app.closer.ui.brand.CloserCopy 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 fun PaywallScreen( onNavigate: (String) -> Unit = {}, @@ -207,13 +199,13 @@ private fun HeaderSection( ) Column(modifier = Modifier.weight(1f)) { Text( - text = "Go deeper together", + text = CloserCopy.Paywall.headline, style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Unlock everything Closer has built for couples.", + text = CloserCopy.Paywall.subhead, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -243,7 +235,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "What's included", + text = CloserCopy.whatsIncluded, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) @@ -252,7 +244,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - BENEFITS.forEach { benefit -> + CloserCopy.Paywall.benefits.forEach { benefit -> BenefitPill(benefit) } } @@ -305,14 +297,14 @@ private fun PlanOptions( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Choose your plan", + text = CloserCopy.Paywall.choosePlanTitle, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) // Couple-shared premium: one purchase unlocks BOTH partners (CouplePremiumChecker). // Framed as a gift — the real, honest hook, no billing changes needed. Text( - text = "One subscription covers you both — treat your partner.", + text = CloserCopy.Paywall.coupleShared, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = Color(0xFF56306F) ) @@ -510,12 +502,12 @@ private fun ThankYouOverlay(onDismiss: () -> Unit) { modifier = Modifier.size(48.dp) ) Text( - text = "You're all set", + text = CloserCopy.Paywall.thankYouTitle, style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Thank you for supporting Closer.", + text = CloserCopy.Paywall.thankYouBody, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center diff --git a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt index 1a7691d1..f14e602c 100644 --- a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt @@ -67,17 +67,9 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import app.closer.ui.brand.CloserCopy 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( val isLoading: Boolean = true, val isPremium: Boolean = false, @@ -213,7 +205,7 @@ private fun PremiumContent( ) { SubscriptionHeroImage() Text( - text = "You're Premium", + text = CloserCopy.Subscription.premiumTitle, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) @@ -238,11 +230,11 @@ private fun PremiumContent( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "What's included", + text = CloserCopy.whatsIncluded, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurfaceVariant ) - BENEFITS.forEach { benefit -> + CloserCopy.Subscription.benefits.forEach { benefit -> Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically @@ -319,14 +311,14 @@ private fun FreeContent( SubscriptionHeroImage() Spacer(Modifier.height(10.dp)) Text( - text = "Unlock Premium", + text = CloserCopy.Subscription.upgradeHeadline, style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) Spacer(Modifier.height(4.dp)) Text( - text = "One subscription for both partners — no double billing.", + text = CloserCopy.Subscription.coupleShared, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center @@ -343,7 +335,7 @@ private fun FreeContent( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - BENEFITS.forEach { benefit -> + CloserCopy.Subscription.benefits.forEach { benefit -> Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically