5.2 KiB
Copy migration — brand-voice copy → CloserCopy
Incremental migration of brand-voice hardcoded copy into the typed
CloserCopycatalog. Builds on the paywall + subscription slice (commit51efff3f). Status tracked per slice below.
Context
The app renders copy from hardcoded Compose literals. A half-built strings.xml catalog was purged
(59e03369) and the voice copy that benefits from centralizing is being lifted into typed Kotlin
catalogs — one reviewable home for tone, dedup of repeated copy, compile-time safety.
Scope — deliberately narrow (this is the key decision)
There are ~281 hardcoded UI-copy literals across the app (settings 64, wheel 42, dates 41,
onboarding 27, home 26, questions 26, pairing 25, auth 17, recap 7, games 6). This migration does NOT
target all of them. Most are functional chrome or screen-specific labels that gain little from a
catalog — and critically, at i18n time every user-facing string must move to res/values/strings.xml
anyway, so pre-moving the bulk into a Kotlin catalog now is wasted double-work.
- IN (migrate now): the app-name ("Closer") lines, duplicated copy (drift risk), and shared value-props/taglines. ~15–25 lines total. These earn a catalog today — they're voice-critical, reused, or repeated.
- OUT (stays inline until i18n): everything else — functional chrome, one-off screen labels, field/error text. No per-screen "voice copy" crusade.
Catalog architecture
Three domain-scoped catalogs, by design (not a smell to unify):
CloserBrandCopy— privacy-rotator lines.GameCopy— game copy.CloserCopy— general product voice; the umbrella for this migration, one nestedobjectper area (Paywall,Subscriptiondone; addAgeGate,Onboarding,Pairing,Settings,Recap,Widget).- Interpolated copy → functions, not consts (e.g.
fun ageError(minAge: Int) = "…$minAge…") — keeps the catalog dependency-free.
Slices (each = one small, compile-gated, reviewable commit)
- Slice 1 — Age-gate (dedup, do first). The min-age error is triplicated verbatim across
SignUpViewModel:62,CreateProfileViewModel:110+:150→ onefun ageError(minAge). The 18+ disclaimer has two genuinely-different variants (SignUpScreenshort "Closer is an 18+ app.";CreateProfileScreen"…We ask once to confirm your age.") → keep both as distinct consts (disclaimer,disclaimerWithReason); do NOT silently unify (that's a copy decision). - Slice 2 — Onboarding + pairing brand lines.
OnboardingScreen:142"Grow Closer" +:261"Closer" wordmark;PairPromptScreen:105"Closer is built for two…". - Slice 3 — Invite share text.
InviteShareText.kt(already a function w/ code+link) →CloserCopy.Pairing. - Slice 4 — Settings, biometric, recap, widget.
SettingsScreen:502/:537subtitles;MainActivity:319.setTitle("Unlock Closer");WeeklyRecapShareCard:37share string +:87canvas.drawText("Closer");TodayWidget:68/95/97. - Everything else → i18n pass. No standalone "long tail" migration. The ~256 remaining literals move
to
strings.xmlin the one mechanical localization pass, not into a Kotlin catalog first.
Per-slice recipe
- Grep target literals; confirm each is unique per file.
- Add to the catalog section, verbatim (functions for interpolation).
- Replace call sites with catalog refs — covers non-Compose sites too:
canvas.drawText(...), biometric.setTitle(...), returned share strings, not justtext = "…". Chrome stays inline. ./gradlew :app:compileDebugKotlin— must stay green.- Run affected unit tests where the slice touches asserted copy — Slice 4 recap must pass
WeeklyRecapShareCardTest(./gradlew :app:testDebugUnitTest --tests "*WeeklyRecapShareCardTest"). Byte-identical relocation keeps them green; running proves it. (FirstRunRenderSmokeTestis an instrumented androidTest asserting onboarding "Closer" — can't run headless, but relocation doesn't change the rendered text.) - Grep: no leftover local copy / old literal in the touched files.
- Commit: "copy relocated verbatim, no behavior change."
Guardrails
- No behavior change — copy is byte-identical, only relocated (compile + tests + diff read confirm).
- Wordmarks relocated only — the "Closer" wordmarks (widget, recap card, onboarding) keep the text
"Closer" per the two-tier rename decision (
docs/NameChange.md); no user-visible word changes. - Check the working tree before each slice (owner may have in-flight edits; it's clean as of this review). Skip any file with uncommitted owner edits until committed.
- i18n end-state: when localization is scheduled, all copy catalogs + the ~256 still-inline literals
migrate to
strings.xmlin one pass (gate recorded inFuture.md).
Status
- Slice 1 (age-gate): ✅ done. Min-age error de-triplicated →
CloserCopy.AgeGate.ageError(minAge); both disclaimer variants + the duplicated DOB prompt consolidated. 6 call sites across SignUp{Screen,ViewModel} + CreateProfile{Screen,ViewModel} now source from the catalog. Compile clean. - Slices 2–4: pending.