feat: signup flow, age gate, user model updates, how well screen, game prompt banner
This commit is contained in:
parent
24823a39f0
commit
f68cab5cf2
|
|
@ -1,6 +1,9 @@
|
|||
# Claude QA Coverage Matrix
|
||||
|
||||
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
|
||||
> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.**
|
||||
> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.**
|
||||
> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b).
|
||||
> **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.**
|
||||
> **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green).
|
||||
> **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sam’s *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens.
|
||||
|
|
@ -33,11 +36,11 @@
|
|||
| K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ |
|
||||
| L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry |
|
||||
| M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred |
|
||||
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ⚠️ **partial** — N-001/N-002 fixed; **Date Memories/Reflection = todo (new R25, needs 2-device live run)** |
|
||||
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ✅ **pass** — N-001/N-002 fixed; **Date Memories/Reflection CLOSED (R26 2-device live: reflect→reveal, edit-before-reveal, notes, bg/fg deep-link, ready/opened pushes + read-failure/timeout hardening)** |
|
||||
| O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** |
|
||||
| P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended |
|
||||
|
||||
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **Open: 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE) — both blocked on the user.**
|
||||
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **R28: HW-BREAKDOWN-001 FIXED+verified (humanize option-ID fallback) + BANNER-RESULTS-COPY-001 (P4) fixed + O-AGE-001 18+ gate IMPLEMENTED+live-verified → archived/mostly-closed.** **Open: 0 P2/P3 blockers.** Remaining non-blocking follow-ups: **O-AGE-001** rules DEPLOYED + birthDate persistence verified against the live rule → only the Play maturity questionnaire remains (product, not code); **BRAND-DARK-COVERAGE** effectively resolved (all 22 `illustration_*` have dark variants; only transparent celebration assets lack one and read fine on dark).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -61,6 +61,7 @@ class FirestoreUserDataSource @Inject constructor(
|
|||
partnerId = data.getString("partnerId"),
|
||||
coupleId = coupleId,
|
||||
plan = data.getString("plan") ?: "free",
|
||||
birthDate = data.getLong("birthDate"),
|
||||
createdAt = data.getLong("createdAt") ?: 0L,
|
||||
lastActiveAt = data.getLong("lastActiveAt") ?: 0L
|
||||
)
|
||||
|
|
@ -98,6 +99,7 @@ class FirestoreUserDataSource @Inject constructor(
|
|||
"partnerId" to user.partnerId,
|
||||
"coupleId" to user.coupleId,
|
||||
"plan" to user.plan,
|
||||
"birthDate" to user.birthDate,
|
||||
"createdAt" to user.createdAt,
|
||||
"lastActiveAt" to user.lastActiveAt
|
||||
)
|
||||
|
|
@ -106,6 +108,10 @@ class FirestoreUserDataSource @Inject constructor(
|
|||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
/** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */
|
||||
suspend fun updateBirthDate(uid: String, birthDateMillis: Long): Unit =
|
||||
setMerge(uid, mapOf("birthDate" to birthDateMillis, "lastActiveAt" to System.currentTimeMillis()))
|
||||
|
||||
suspend fun updateDisplayName(uid: String, displayName: String) {
|
||||
require(displayName != FieldEncryptor.LOCKED_PLACEHOLDER) {
|
||||
"Refusing to persist the locked placeholder as displayName"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ class UserRepositoryImpl @Inject constructor(
|
|||
override suspend fun updateSex(uid: String, sex: String) =
|
||||
dataSource.updateSex(uid, sex)
|
||||
|
||||
override suspend fun updateBirthDate(uid: String, birthDateMillis: Long) =
|
||||
dataSource.updateBirthDate(uid, birthDateMillis)
|
||||
|
||||
override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
|
||||
|
||||
override suspend fun storeFcmToken(uid: String, token: String) =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package app.closer.domain
|
||||
|
||||
import java.util.Calendar
|
||||
|
||||
/**
|
||||
* Single source of truth for the 18+ age gate (O-AGE-001). Closer ships adult-intimacy content
|
||||
* (Desire Sync), so sign-up requires a date of birth and blocks anyone under [MIN_AGE]. The birth
|
||||
* date is stored plaintext on the user doc (not E2EE) so it stays auditable for the store content
|
||||
* rating — it is not relationship content.
|
||||
*/
|
||||
object AgeGate {
|
||||
const val MIN_AGE = 18
|
||||
|
||||
/** Whole years old at [nowMillis] for someone born at [birthDateMillis]. */
|
||||
fun ageInYears(birthDateMillis: Long, nowMillis: Long = System.currentTimeMillis()): Int {
|
||||
val dob = Calendar.getInstance().apply { timeInMillis = birthDateMillis }
|
||||
val now = Calendar.getInstance().apply { timeInMillis = nowMillis }
|
||||
var age = now.get(Calendar.YEAR) - dob.get(Calendar.YEAR)
|
||||
val monthDiff = now.get(Calendar.MONTH) - dob.get(Calendar.MONTH)
|
||||
if (monthDiff < 0 ||
|
||||
(monthDiff == 0 && now.get(Calendar.DAY_OF_MONTH) < dob.get(Calendar.DAY_OF_MONTH))
|
||||
) {
|
||||
age--
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
/** True only if [birthDateMillis] is a real past date and the person is at least [MIN_AGE]. */
|
||||
fun isAdult(birthDateMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
birthDateMillis in 1 until nowMillis && ageInYears(birthDateMillis, nowMillis) >= MIN_AGE
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package app.closer.domain
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
|
||||
* for the 18+ gate, but PERSISTED at profile creation: a Firestore write issued immediately after
|
||||
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules). So
|
||||
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
|
||||
* once the session has settled — and skips re-asking. Process death clears this; CreateProfile then
|
||||
* re-asks via its own DOB step (the gate still holds).
|
||||
*/
|
||||
@Singleton
|
||||
class SignupHandoff @Inject constructor() {
|
||||
var pendingBirthDate: Long? = null
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@ data class User(
|
|||
val partnerId: String? = null,
|
||||
val coupleId: String? = null,
|
||||
val plan: String = "free",
|
||||
// Date of birth (epoch millis, local midnight) captured for the 18+ age gate (O-AGE-001).
|
||||
// Null for legacy users created before the gate + partners we can't see. Stored plaintext
|
||||
// (not E2EE) — it's a compliance/rating field, not relationship content. See [app.closer.domain.AgeGate].
|
||||
val birthDate: Long? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val lastActiveAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface UserRepository {
|
|||
suspend fun updateDisplayName(uid: String, displayName: String)
|
||||
suspend fun updatePhotoUrl(uid: String, photoUrl: String)
|
||||
suspend fun updateSex(uid: String, sex: String)
|
||||
suspend fun updateBirthDate(uid: String, birthDateMillis: Long)
|
||||
suspend fun hasProfile(uid: String): Boolean
|
||||
suspend fun storeFcmToken(uid: String, token: String)
|
||||
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import android.util.Log
|
|||
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -190,6 +193,63 @@ fun SignUpScreen(
|
|||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signUp() })
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 18+ age gate (O-AGE-001). Read-only field that opens a date picker; the overlay Box
|
||||
// captures taps reliably (a readOnly OutlinedTextField doesn't forward clicks). Max date
|
||||
// is today; default lands ~18 years back so the common case is one scroll away.
|
||||
val dobLabel = state.birthDate?.let {
|
||||
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
|
||||
} ?: ""
|
||||
val openDobPicker = {
|
||||
focusManager.clearFocus()
|
||||
val initMillis = state.birthDate
|
||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||
android.app.DatePickerDialog(
|
||||
context,
|
||||
{ _, y, m, d ->
|
||||
viewModel.updateBirthDate(
|
||||
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
||||
)
|
||||
},
|
||||
c.get(java.util.Calendar.YEAR),
|
||||
c.get(java.util.Calendar.MONTH),
|
||||
c.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = dobLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Date of birth") },
|
||||
placeholder = { Text("You must be 18 or older") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = authTextFieldColors(),
|
||||
trailingIcon = {
|
||||
Icon(CloserGlyphs.Cake, contentDescription = null, tint = AuthMuted)
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
"Closer is an 18+ app.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = AuthMuted
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = openDobPicker
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Button(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package app.closer.ui.auth
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.domain.AgeGate
|
||||
import app.closer.domain.SignupHandoff
|
||||
import app.closer.domain.model.GoogleSignInResult
|
||||
import app.closer.domain.model.User
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
|
|
@ -18,6 +20,8 @@ data class SignUpUiState(
|
|||
val email: String = "",
|
||||
val password: String = "",
|
||||
val confirmPassword: String = "",
|
||||
// 18+ age gate (O-AGE-001): DOB captured as epoch millis (local midnight). Null until picked.
|
||||
val birthDate: Long? = null,
|
||||
val isPasswordVisible: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
|
|
@ -30,7 +34,8 @@ data class SignUpUiState(
|
|||
@HiltViewModel
|
||||
class SignUpViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val signupHandoff: SignupHandoff
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SignUpUiState())
|
||||
|
|
@ -39,17 +44,22 @@ class SignUpViewModel @Inject constructor(
|
|||
fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
|
||||
fun updatePassword(v: String) = _uiState.update { it.copy(password = v, error = null) }
|
||||
fun updateConfirmPassword(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) }
|
||||
fun updateBirthDate(millis: Long) = _uiState.update { it.copy(birthDate = millis, error = null) }
|
||||
fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
fun signUp() {
|
||||
val state = _uiState.value
|
||||
val pw = state.password
|
||||
val dob = state.birthDate
|
||||
when {
|
||||
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return }
|
||||
pw.length < 8 -> { _uiState.update { it.copy(error = "Password must be at least 8 characters.") }; 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 }
|
||||
// 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 }
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
|
|
@ -57,6 +67,11 @@ class SignUpViewModel @Inject constructor(
|
|||
.onSuccess {
|
||||
// Best-effort: send a verification email. Don't block account creation if it fails.
|
||||
authRepository.sendEmailVerification()
|
||||
// Hand the validated DOB to CreateProfile to persist. We do NOT write it here: a
|
||||
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
||||
// and silently fails rules. CreateProfile writes it once the session has settled and
|
||||
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
|
||||
signupHandoff.pendingBirthDate = dob
|
||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
|
|
|
|||
|
|
@ -140,13 +140,21 @@ private data class PromptStyle(
|
|||
// Single client source of truth for the banner copy. Mirror any change in the Cloud Function copy
|
||||
// (functions/src/games/onGameSessionUpdate.ts notifyPartner) so foreground/background stay in sync.
|
||||
private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
|
||||
val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner"
|
||||
// `resolved` is the partner's real (locally-decrypted) display name when we have it; `name` is the
|
||||
// sentence-START label (capitalized fallback reads fine there). Mid-sentence copy must NOT use the
|
||||
// capitalized generic fallback ("…and Your partner compare"), so those lines branch on `resolved`.
|
||||
val resolved = prompt.partnerName?.takeIf { it.isNotBlank() }
|
||||
val name = resolved ?: "Your partner"
|
||||
val game = gameDisplayName(prompt.gameType)
|
||||
return when (prompt.kind) {
|
||||
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
|
||||
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
|
||||
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play")
|
||||
GamePromptKind.RESULTS -> PromptStyle("You both finished", "See how you and $name compare", "View")
|
||||
GamePromptKind.RESULTS -> PromptStyle(
|
||||
"You both finished",
|
||||
if (resolved != null) "See how you and $resolved compare" else "See how you both compare",
|
||||
"View"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,15 +106,20 @@ fun HowWellAnswer.isClose(other: HowWellAnswer): Boolean =
|
|||
scaleValue != null && other.scaleValue != null &&
|
||||
!isMatch(other) && abs(scaleValue - other.scaleValue) == 1
|
||||
|
||||
/** Turn a raw option id ("a_small_romantic_surprise") into readable text as a last resort, so a slug can
|
||||
* never leak into the UI when the id isn't found in the question's config (HW-BREAKDOWN-001). */
|
||||
private fun humanizeOptionId(id: String): String =
|
||||
id.replace('_', ' ').trim().replaceFirstChar { it.uppercase() }
|
||||
|
||||
fun HowWellAnswer.displayText(config: AnswerConfig?): String = when {
|
||||
selectedOptionId != null -> when (config) {
|
||||
is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: selectedOptionId
|
||||
is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: humanizeOptionId(selectedOptionId)
|
||||
is ThisOrThatAnswerConfigImpl -> when (selectedOptionId) {
|
||||
config.config.optionA.id -> config.config.optionA.text
|
||||
config.config.optionB.id -> config.config.optionB.text
|
||||
else -> selectedOptionId
|
||||
else -> humanizeOptionId(selectedOptionId)
|
||||
}
|
||||
else -> selectedOptionId
|
||||
else -> humanizeOptionId(selectedOptionId)
|
||||
}
|
||||
scaleValue != null -> "$scaleValue"
|
||||
else -> "—"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -129,7 +130,8 @@ fun CreateProfileScreen(
|
|||
containerColor = Color.Transparent,
|
||||
modifier = Modifier.background(AuthBackgroundBrush),
|
||||
topBar = {
|
||||
if (state.currentStep != ProfileStep.NAME) {
|
||||
val firstStep = if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME
|
||||
if (state.currentStep != firstStep) {
|
||||
TopAppBar(
|
||||
title = { Text("Create profile", color = AuthInk) },
|
||||
navigationIcon = {
|
||||
|
|
@ -159,15 +161,42 @@ fun CreateProfileScreen(
|
|||
) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
// Total is 4 when the DOB step is shown (Google/legacy), else 3. DOB is enum-ordinal 0, so
|
||||
// the non-DOB flow uses the ordinal as-is (NAME=1) and the DOB flow uses ordinal+1 (DOB=1).
|
||||
val totalSteps = if (state.requireBirthDate) 4 else 3
|
||||
val stepNumber = if (state.requireBirthDate) state.currentStep.ordinal + 1 else state.currentStep.ordinal
|
||||
Text(
|
||||
text = "Step ${state.currentStep.ordinal + 1} of 3",
|
||||
text = "Step $stepNumber of $totalSteps",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = AuthMuted,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
val openDobPicker = {
|
||||
focusManager.clearFocus()
|
||||
val initMillis = state.birthDate
|
||||
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||
android.app.DatePickerDialog(
|
||||
context,
|
||||
{ _, y, m, d ->
|
||||
viewModel.selectBirthDate(
|
||||
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
||||
)
|
||||
},
|
||||
c.get(java.util.Calendar.YEAR),
|
||||
c.get(java.util.Calendar.MONTH),
|
||||
c.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
||||
}
|
||||
|
||||
when (state.currentStep) {
|
||||
ProfileStep.DOB -> DobStep(
|
||||
state = state,
|
||||
onPickDate = openDobPicker,
|
||||
onContinue = viewModel::goToNextStep
|
||||
)
|
||||
ProfileStep.NAME -> NameStep(
|
||||
state = state,
|
||||
onNameChange = viewModel::updateDisplayName,
|
||||
|
|
@ -200,6 +229,74 @@ fun CreateProfileScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DobStep(
|
||||
state: CreateProfileUiState,
|
||||
onPickDate: () -> Unit,
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "How old are you?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = AuthInk,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Closer is an 18+ app. We ask once to confirm your age.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = AuthMuted,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
val dobLabel = state.birthDate?.let {
|
||||
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
|
||||
} ?: ""
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = dobLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Date of birth") },
|
||||
placeholder = { Text("Tap to choose") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = authTextFieldColors(),
|
||||
isError = state.birthDateError != null,
|
||||
trailingIcon = { Icon(CloserGlyphs.Cake, contentDescription = null, tint = AuthMuted) },
|
||||
supportingText = state.birthDateError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onPickDate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AuthPrimary,
|
||||
contentColor = AuthOnPrimary
|
||||
)
|
||||
) {
|
||||
if (state.isLoading) CloserHeartLoader(size = 22.dp)
|
||||
else Text("Continue", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameStep(
|
||||
state: CreateProfileUiState,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import android.net.Uri
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.data.remote.FirebaseStorageDataSource
|
||||
import app.closer.domain.AgeGate
|
||||
import app.closer.domain.SignupHandoff
|
||||
import app.closer.domain.model.User
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.UserRepository
|
||||
|
|
@ -16,6 +18,7 @@ import kotlinx.coroutines.launch
|
|||
import javax.inject.Inject
|
||||
|
||||
enum class ProfileStep {
|
||||
DOB,
|
||||
NAME,
|
||||
SEX,
|
||||
PHOTO
|
||||
|
|
@ -27,6 +30,11 @@ data class CreateProfileUiState(
|
|||
val sex: String = "",
|
||||
val photoUrl: String = "",
|
||||
val photoUri: String? = null,
|
||||
// 18+ age gate (O-AGE-001). Shown as the first step ONLY for users who arrive without a DOB
|
||||
// (Google sign-in / legacy); email sign-up already captured + persisted it, so they skip it.
|
||||
val birthDate: Long? = null,
|
||||
val requireBirthDate: Boolean = false,
|
||||
val birthDateError: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false,
|
||||
|
|
@ -38,7 +46,8 @@ data class CreateProfileUiState(
|
|||
class CreateProfileViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val storageDataSource: FirebaseStorageDataSource
|
||||
private val storageDataSource: FirebaseStorageDataSource,
|
||||
private val signupHandoff: SignupHandoff
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(CreateProfileUiState())
|
||||
|
|
@ -51,12 +60,22 @@ class CreateProfileViewModel @Inject constructor(
|
|||
private fun loadExistingProfile() {
|
||||
val uid = authRepository.currentUserId ?: return
|
||||
viewModelScope.launch {
|
||||
val user = runCatching { userRepository.getUser(uid) }.getOrNull() ?: return@launch
|
||||
val user = runCatching { userRepository.getUser(uid) }.getOrNull()
|
||||
// Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory,
|
||||
// since the post-signup write is unreliable). Require the DOB step only when we have neither
|
||||
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
|
||||
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
|
||||
val needsDob = effectiveDob == null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
displayName = user.displayName,
|
||||
sex = user.sex,
|
||||
photoUrl = user.photoUrl
|
||||
displayName = user?.displayName ?: it.displayName,
|
||||
sex = user?.sex ?: it.sex,
|
||||
photoUrl = user?.photoUrl ?: it.photoUrl,
|
||||
birthDate = effectiveDob,
|
||||
requireBirthDate = needsDob,
|
||||
// Promote the DOB step to first only if the user is still at the default entry step
|
||||
// (don't yank them backwards if they've already advanced).
|
||||
currentStep = if (needsDob && it.currentStep == ProfileStep.NAME) ProfileStep.DOB else it.currentStep
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,11 +85,15 @@ class CreateProfileViewModel @Inject constructor(
|
|||
|
||||
fun selectSex(sex: String) = _uiState.update { it.copy(sex = sex, sexError = null, error = null) }
|
||||
|
||||
fun selectBirthDate(millis: Long) = _uiState.update { it.copy(birthDate = millis, birthDateError = null, error = null) }
|
||||
|
||||
fun setPhotoUri(uri: String?) = _uiState.update { it.copy(photoUri = uri, error = null) }
|
||||
|
||||
fun goBack() {
|
||||
val previous = when (_uiState.value.currentStep) {
|
||||
ProfileStep.NAME -> ProfileStep.NAME
|
||||
val state = _uiState.value
|
||||
val previous = when (state.currentStep) {
|
||||
ProfileStep.DOB -> ProfileStep.DOB
|
||||
ProfileStep.NAME -> if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME
|
||||
ProfileStep.SEX -> ProfileStep.NAME
|
||||
ProfileStep.PHOTO -> ProfileStep.SEX
|
||||
}
|
||||
|
|
@ -80,6 +103,14 @@ class CreateProfileViewModel @Inject constructor(
|
|||
fun goToNextStep() {
|
||||
val state = _uiState.value
|
||||
when (state.currentStep) {
|
||||
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.") }
|
||||
else -> _uiState.update { it.copy(currentStep = ProfileStep.NAME, birthDateError = null) }
|
||||
}
|
||||
}
|
||||
ProfileStep.NAME -> {
|
||||
val name = state.displayName.trim()
|
||||
when {
|
||||
|
|
@ -113,6 +144,12 @@ class CreateProfileViewModel @Inject constructor(
|
|||
}
|
||||
return
|
||||
}
|
||||
// 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.") }
|
||||
return
|
||||
}
|
||||
val uid = authRepository.currentUserId ?: run {
|
||||
_uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") }
|
||||
return
|
||||
|
|
@ -131,19 +168,28 @@ class CreateProfileViewModel @Inject constructor(
|
|||
displayName = name,
|
||||
sex = state.sex,
|
||||
photoUrl = finalPhotoUrl,
|
||||
birthDate = state.birthDate,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
lastActiveAt = System.currentTimeMillis()
|
||||
)
|
||||
if (existing == null) {
|
||||
userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl))
|
||||
userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl, birthDate = state.birthDate))
|
||||
} else {
|
||||
userRepository.updateDisplayName(uid, name)
|
||||
userRepository.updateSex(uid, state.sex)
|
||||
if (finalPhotoUrl.isNotBlank()) {
|
||||
userRepository.updatePhotoUrl(uid, finalPhotoUrl)
|
||||
}
|
||||
// Persist the DOB captured this session for users whose doc predates the age gate.
|
||||
// Best-effort: the age gate is already enforced client-side, so an audit-only write
|
||||
// failing (e.g. transient/rules) must never block profile creation.
|
||||
if (existing.birthDate == null && state.birthDate != null) {
|
||||
runCatching { userRepository.updateBirthDate(uid, state.birthDate) }
|
||||
}
|
||||
}
|
||||
}.onSuccess {
|
||||
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
|
||||
signupHandoff.pendingBirthDate = null
|
||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
package app.closer.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Calendar
|
||||
|
||||
class AgeGateTest {
|
||||
|
||||
// Fixed "now" = 2026-07-02 noon, so the age math is deterministic.
|
||||
private fun now(): Long = Calendar.getInstance().apply {
|
||||
clear(); set(2026, Calendar.JULY, 2, 12, 0, 0)
|
||||
}.timeInMillis
|
||||
|
||||
private fun dob(year: Int, month: Int, day: Int): Long =
|
||||
Calendar.getInstance().apply { clear(); set(year, month, day) }.timeInMillis
|
||||
|
||||
@Test
|
||||
fun `turns 18 exactly today counts as adult`() {
|
||||
assertTrue(AgeGate.isAdult(dob(2008, Calendar.JULY, 2), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one day short of 18 is not adult`() {
|
||||
assertFalse(AgeGate.isAdult(dob(2008, Calendar.JULY, 3), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearly over 18 is adult`() {
|
||||
assertTrue(AgeGate.isAdult(dob(2001, Calendar.JANUARY, 1), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seventeen is not adult`() {
|
||||
assertFalse(AgeGate.isAdult(dob(2009, Calendar.JANUARY, 1), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `future birth date is rejected`() {
|
||||
assertFalse(AgeGate.isAdult(dob(2030, Calendar.JANUARY, 1), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero or negative epoch is rejected`() {
|
||||
assertFalse(AgeGate.isAdult(0L, now()))
|
||||
assertFalse(AgeGate.isAdult(-1L, now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age in years is correct across the birthday boundary`() {
|
||||
assertEquals(18, AgeGate.ageInYears(dob(2008, Calendar.JULY, 2), now()))
|
||||
assertEquals(17, AgeGate.ageInYears(dob(2008, Calendar.JULY, 3), now()))
|
||||
assertEquals(25, AgeGate.ageInYears(dob(2001, Calendar.JULY, 2), now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimum age is eighteen`() {
|
||||
assertEquals(18, AgeGate.MIN_AGE)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,32 @@ Reviewed Compose UI files under `app/src/main/java/app/closer/ui/` for responsiv
|
|||
## Build Verification
|
||||
`./gradlew :app:compileDebugKotlin` → **BUILD SUCCESSFUL** (3s)
|
||||
|
||||
## Correction (R25 review — some Batch-8 truncations reverted)
|
||||
|
||||
A follow-up review found that Batch 8 over-applied `maxLines`+`TextOverflow.Ellipsis`: it's correct for
|
||||
*chrome* (one-line titles, labels, pills, counts) but wrong for *content and errors*, where it silently
|
||||
hides what a partner wrote. The rule is now **ellipsize chrome, never content or errors; bound content at
|
||||
input instead.** The following Batch-8 entries below were **reverted** (the code no longer truncates them):
|
||||
|
||||
- **`questions/components/QuestionDiscussionThread.kt`** — message bubble `maxLines=10` **removed** (messages
|
||||
wrap in full; was also inconsistent with the main `ConversationScreen`, which never truncated bubbles).
|
||||
- **`questions/components/AnswerBubble.kt`** — `maxLines=5` on the answer bubble **removed** (it was
|
||||
ellipsizing full written answers).
|
||||
- **`questions/components/QuestionHeader.kt`** — `maxLines=6` on the question text **removed** (the question
|
||||
is the screen's focal content and wraps).
|
||||
- **`settings/RelationshipSettingsScreen.kt`** — truncation on the **explanation + error** text **removed**
|
||||
(the `TopAppBar` title's `maxLines=1` was **kept** — that's chrome).
|
||||
|
||||
Instead of display truncation, free-text is **bounded at entry** in the ViewModels, centralized in
|
||||
`ui/components/TextInputLimits.kt` (`MESSAGE` 2000 · `DISCUSSION_MESSAGE` 500 · `WRITTEN_ANSWER` 2000; the
|
||||
conversation/discussion/question-detail/question-thread/wheel VMs alias those, and chat/discussion/wheel/
|
||||
written-answer `.trim()` on send). The spin-the-wheel written answer — the one input that was genuinely
|
||||
uncapped — is now capped. The shared written-answer field shows a character counter only within
|
||||
`TextInputLimits.COUNTER_THRESHOLD` of the cap. (See `ClaudeQAPlan.md` → Pass J.)
|
||||
|
||||
**Note:** the `components/PlaceholderScreen.kt` entry below refers to a file that no longer exists in the
|
||||
tree (removed/renamed since Batch 8) — disregard it.
|
||||
|
||||
## Per-File Findings
|
||||
|
||||
### `home/HomeScreen.kt`
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ service cloud.firestore {
|
|||
allow update: if isOwner(uid)
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
|
||||
'plan', 'createdAt', 'lastActiveAt', 'fcmToken',
|
||||
'plan', 'birthDate', 'createdAt', 'lastActiveAt', 'fcmToken',
|
||||
'notifPartnerAnswered', 'notifChatMessage',
|
||||
// Daily/streak/promotional prefs mirrored so the scheduled senders can honor them.
|
||||
'notifDailyReminder', 'notifStreakReminder', 'notifPromotional',
|
||||
|
|
|
|||
Loading…
Reference in New Issue