refactor(copy): Slice 1 — dedup age-gate copy into CloserCopy.AgeGate
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 <noreply@anthropic.com>
This commit is contained in:
parent
0195564d25
commit
23cd213953
|
|
@ -1,6 +1,7 @@
|
||||||
package app.closer.ui.auth
|
package app.closer.ui.auth
|
||||||
|
|
||||||
import app.closer.R
|
import app.closer.R
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import androidx.credentials.CredentialManager
|
import androidx.credentials.CredentialManager
|
||||||
import androidx.credentials.CustomCredential
|
import androidx.credentials.CustomCredential
|
||||||
import androidx.credentials.GetCredentialRequest
|
import androidx.credentials.GetCredentialRequest
|
||||||
|
|
@ -229,7 +230,7 @@ fun SignUpScreen(
|
||||||
},
|
},
|
||||||
supportingText = {
|
supportingText = {
|
||||||
Text(
|
Text(
|
||||||
"Closer is an 18+ app.",
|
CloserCopy.AgeGate.disclaimer,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AuthMuted
|
color = AuthMuted
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.domain.AgeGate
|
import app.closer.domain.AgeGate
|
||||||
import app.closer.domain.SignupHandoff
|
import app.closer.domain.SignupHandoff
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import app.closer.domain.model.GoogleSignInResult
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
import app.closer.domain.model.User
|
import app.closer.domain.model.User
|
||||||
import app.closer.domain.repository.AuthRepository
|
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.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 }
|
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.
|
// 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 }
|
dob == null -> { _uiState.update { it.copy(error = CloserCopy.AgeGate.dobRequired) }; return }
|
||||||
!AgeGate.isAdult(dob) -> { _uiState.update { it.copy(error = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") }; return }
|
!AgeGate.isAdult(dob) -> { _uiState.update { it.copy(error = CloserCopy.AgeGate.ageError(AgeGate.MIN_AGE)) }; return }
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,14 @@ object CloserCopy {
|
||||||
"Memory Lane time capsules",
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import app.closer.ui.components.CloserHeartLoader
|
import app.closer.ui.components.CloserHeartLoader
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -240,7 +241,7 @@ private fun DobStep(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Closer is an 18+ app. We ask once to confirm your age.",
|
text = CloserCopy.AgeGate.disclaimerWithReason,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AuthMuted,
|
color = AuthMuted,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.data.remote.FirebaseStorageDataSource
|
import app.closer.data.remote.FirebaseStorageDataSource
|
||||||
import app.closer.domain.AgeGate
|
import app.closer.domain.AgeGate
|
||||||
import app.closer.domain.SignupHandoff
|
import app.closer.domain.SignupHandoff
|
||||||
|
import app.closer.ui.brand.CloserCopy
|
||||||
import app.closer.domain.model.User
|
import app.closer.domain.model.User
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
|
|
@ -106,8 +107,8 @@ class CreateProfileViewModel @Inject constructor(
|
||||||
ProfileStep.DOB -> {
|
ProfileStep.DOB -> {
|
||||||
val dob = state.birthDate
|
val dob = state.birthDate
|
||||||
when {
|
when {
|
||||||
dob == null -> _uiState.update { it.copy(birthDateError = "Please enter your date of birth.") }
|
dob == null -> _uiState.update { it.copy(birthDateError = CloserCopy.AgeGate.dobRequired) }
|
||||||
!AgeGate.isAdult(dob) -> _uiState.update { it.copy(birthDateError = "You must be at least ${AgeGate.MIN_AGE} to use Closer.") }
|
!AgeGate.isAdult(dob) -> _uiState.update { it.copy(birthDateError = CloserCopy.AgeGate.ageError(AgeGate.MIN_AGE)) }
|
||||||
else -> _uiState.update { it.copy(currentStep = ProfileStep.NAME, birthDateError = null) }
|
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+
|
// 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).
|
// (defense-in-depth behind the DOB step; also covers a skipped/bypassed step).
|
||||||
if (state.requireBirthDate && (state.birthDate == null || !AgeGate.isAdult(state.birthDate))) {
|
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
|
return
|
||||||
}
|
}
|
||||||
val uid = authRepository.currentUserId ?: run {
|
val uid = authRepository.currentUserId ?: run {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue