2026-07-11 18:29:08 -05:00
|
|
|
|
# Copy migration — brand-voice copy → `CloserCopy` [CLOSED]
|
|
|
|
|
|
|
|
|
|
|
|
> **⛔ CLOSED (2026-07-11) — stopped by owner decision after Slice 1.** The ROI review concluded
|
|
|
|
|
|
> pure copy-relocation wasn't worth the churn; effort went to higher-value work instead (server/
|
|
|
|
|
|
> removal, benefit-list reconciliation). **Slices 2–4 are CANCELLED, not pending.** The catalog's
|
|
|
|
|
|
> scope is frozen at paywall + subscription + age-gate (`ui/brand/CloserCopy.kt`). Also note: the
|
|
|
|
|
|
> 2026-07-11 "Closer Couples" in-app prose rename REWROTE many strings quoted below (e.g. the
|
|
|
|
|
|
> age-gate disclaimer is now "Closer Couples is an 18+ app.") — treat quoted copy as historical.
|
|
|
|
|
|
> Rename record: `docs/NameChange.md`.
|
|
|
|
|
|
>
|
2026-07-09 00:39:04 -05:00
|
|
|
|
> Incremental migration of **brand-voice** hardcoded copy into the typed `CloserCopy` catalog.
|
|
|
|
|
|
> Builds on the paywall + subscription slice (commit `51efff3f`). 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 nested `object` per area
|
|
|
|
|
|
(`Paywall`, `Subscription` done; add `AgeGate`, `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` → one `fun ageError(minAge)`. The 18+
|
|
|
|
|
|
disclaimer has **two genuinely-different variants** (`SignUpScreen` short "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/:537` subtitles;
|
|
|
|
|
|
`MainActivity:319` `.setTitle("Unlock Closer")`; `WeeklyRecapShareCard:37` share string + `:87`
|
|
|
|
|
|
`canvas.drawText("Closer")`; `TodayWidget:68/95/97`.
|
|
|
|
|
|
- **Everything else → i18n pass.** No standalone "long tail" migration. The ~256 remaining literals move
|
|
|
|
|
|
to `strings.xml` in the one mechanical localization pass, not into a Kotlin catalog first.
|
|
|
|
|
|
|
|
|
|
|
|
## Per-slice recipe
|
|
|
|
|
|
|
|
|
|
|
|
1. Grep target literals; confirm each is unique per file.
|
|
|
|
|
|
2. Add to the catalog section, verbatim (functions for interpolation).
|
|
|
|
|
|
3. Replace call sites with catalog refs — **covers non-Compose sites too**: `canvas.drawText(...)`,
|
|
|
|
|
|
biometric `.setTitle(...)`, returned share strings, not just `text = "…"`. Chrome stays inline.
|
|
|
|
|
|
4. `./gradlew :app:compileDebugKotlin` — must stay green.
|
|
|
|
|
|
5. **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. (`FirstRunRenderSmokeTest` is an
|
|
|
|
|
|
instrumented androidTest asserting onboarding "Closer" — can't run headless, but relocation doesn't
|
|
|
|
|
|
change the rendered text.)
|
|
|
|
|
|
6. Grep: no leftover local copy / old literal in the touched files.
|
|
|
|
|
|
7. 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.xml` in one pass (gate recorded in `Future.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.
|
2026-07-11 18:29:08 -05:00
|
|
|
|
- Slices 2–4: **cancelled** (2026-07-11 close-out — see header). Several of their target strings were
|
|
|
|
|
|
since edited in place by the "Closer Couples" prose rename instead.
|