From 23cd2139538d221d02d2c83fb8392441d65906cd Mon Sep 17 00:00:00 2001 From: null Date: Thu, 9 Jul 2026 00:39:04 -0500 Subject: [PATCH] =?UTF-8?q?refactor(copy):=20Slice=201=20=E2=80=94=20dedup?= =?UTF-8?q?=20age-gate=20copy=20into=20CloserCopy.AgeGate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 18+ min-age error was triplicated verbatim (SignUpViewModel, CreateProfileViewModel ×2); consolidated to CloserCopy.AgeGate.ageError(minAge). Also lifted the two disclaimer variants (kept distinct: brief on sign-up, with-reason on the DOB step — not unified) and the duplicated "Please enter your date of birth." prompt. 6 call sites now source from the catalog; copy is byte-identical (no behavior change), compile clean. Adds docs/CopyMigration.md — the migration plan, scoped (after a gap review) to the ~15-25 brand-voice/duplicated lines, NOT all ~281 UI literals: the bulk stays inline until the single i18n → strings.xml pass, since double-migrating to a Kotlin catalog first is wasted work. Co-Authored-By: Claude Opus 4.8 --- .../java/app/closer/ui/auth/SignUpScreen.kt | 3 +- .../app/closer/ui/auth/SignUpViewModel.kt | 5 +- .../java/app/closer/ui/brand/CloserCopy.kt | 10 +++ .../ui/onboarding/CreateProfileScreen.kt | 3 +- .../ui/onboarding/CreateProfileViewModel.kt | 7 +- docs/CopyMigration.md | 83 +++++++++++++++++++ 6 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 docs/CopyMigration.md diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index f6d835f8..dcd9c44f 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -1,6 +1,7 @@ package app.closer.ui.auth import app.closer.R +import app.closer.ui.brand.CloserCopy import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest @@ -229,7 +230,7 @@ fun SignUpScreen( }, supportingText = { Text( - "Closer is an 18+ app.", + CloserCopy.AgeGate.disclaimer, style = MaterialTheme.typography.bodySmall, color = AuthMuted ) diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index c525a527..89651023 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.domain.AgeGate import app.closer.domain.SignupHandoff +import app.closer.ui.brand.CloserCopy import app.closer.domain.model.GoogleSignInResult import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository @@ -58,8 +59,8 @@ class SignUpViewModel @Inject constructor( !pw.any { it.isLetter() } || !pw.any { it.isDigit() } -> { _uiState.update { it.copy(error = "Password must include both letters and numbers.") }; return } pw != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return } // 18+ age gate (O-AGE-001): validate BEFORE creating any account so no under-age account exists. - dob == null -> { _uiState.update { it.copy(error = "Please enter your date of birth.") }; return } - !AgeGate.isAdult(dob) -> { _uiState.update { it.copy(error = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") }; return } + dob == null -> { _uiState.update { it.copy(error = CloserCopy.AgeGate.dobRequired) }; return } + !AgeGate.isAdult(dob) -> { _uiState.update { it.copy(error = CloserCopy.AgeGate.ageError(AgeGate.MIN_AGE)) }; return } } _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { diff --git a/app/src/main/java/app/closer/ui/brand/CloserCopy.kt b/app/src/main/java/app/closer/ui/brand/CloserCopy.kt index 76bd02d2..c6f41565 100644 --- a/app/src/main/java/app/closer/ui/brand/CloserCopy.kt +++ b/app/src/main/java/app/closer/ui/brand/CloserCopy.kt @@ -55,4 +55,14 @@ object CloserCopy { "Memory Lane time capsules", ) } + + /** 18+ gate copy (O-AGE-001). Consolidated from SignUp + CreateProfile, where the min-age + * error was triplicated verbatim. Two disclaimer variants are intentionally distinct + * (brief on sign-up, with-reason on the DOB step) — kept separate, not unified. */ + object AgeGate { + const val disclaimer = "Closer is an 18+ app." + const val disclaimerWithReason = "Closer is an 18+ app. We ask once to confirm your age." + const val dobRequired = "Please enter your date of birth." + fun ageError(minAge: Int) = "You must be at least $minAge to use Closer." + } } diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt index 8b9e6ea2..8cd9eb19 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import app.closer.ui.brand.CloserCopy import app.closer.ui.components.CloserHeartLoader import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -240,7 +241,7 @@ private fun DobStep( ) Spacer(Modifier.height(8.dp)) Text( - text = "Closer is an 18+ app. We ask once to confirm your age.", + text = CloserCopy.AgeGate.disclaimerWithReason, style = MaterialTheme.typography.bodyMedium, color = AuthMuted, textAlign = TextAlign.Center diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt index bd223104..de268e37 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import app.closer.data.remote.FirebaseStorageDataSource import app.closer.domain.AgeGate import app.closer.domain.SignupHandoff +import app.closer.ui.brand.CloserCopy import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.UserRepository @@ -106,8 +107,8 @@ class CreateProfileViewModel @Inject constructor( ProfileStep.DOB -> { val dob = state.birthDate when { - dob == null -> _uiState.update { it.copy(birthDateError = "Please enter your date of birth.") } - !AgeGate.isAdult(dob) -> _uiState.update { it.copy(birthDateError = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") } + dob == null -> _uiState.update { it.copy(birthDateError = CloserCopy.AgeGate.dobRequired) } + !AgeGate.isAdult(dob) -> _uiState.update { it.copy(birthDateError = CloserCopy.AgeGate.ageError(AgeGate.MIN_AGE)) } else -> _uiState.update { it.copy(currentStep = ProfileStep.NAME, birthDateError = null) } } } @@ -147,7 +148,7 @@ class CreateProfileViewModel @Inject constructor( // 18+ age gate (O-AGE-001): never persist a profile for a user we can't confirm is 18+ // (defense-in-depth behind the DOB step; also covers a skipped/bypassed step). if (state.requireBirthDate && (state.birthDate == null || !AgeGate.isAdult(state.birthDate))) { - _uiState.update { it.copy(currentStep = ProfileStep.DOB, birthDateError = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") } + _uiState.update { it.copy(currentStep = ProfileStep.DOB, birthDateError = CloserCopy.AgeGate.ageError(AgeGate.MIN_AGE)) } return } val uid = authRepository.currentUserId ?: run { diff --git a/docs/CopyMigration.md b/docs/CopyMigration.md new file mode 100644 index 00000000..873fae46 --- /dev/null +++ b/docs/CopyMigration.md @@ -0,0 +1,83 @@ +# Copy migration — brand-voice copy → `CloserCopy` + +> 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. +- Slices 2–4: pending.