feat: signup flow, age gate, user model updates, how well screen, game prompt banner

This commit is contained in:
null 2026-07-02 02:42:55 -05:00
parent 24823a39f0
commit f68cab5cf2
17 changed files with 411 additions and 22 deletions

View File

@ -1,6 +1,9 @@
# Claude QA Coverage Matrix # Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. > **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 (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-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 Sams *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. > **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 Sams *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 ✅ | | 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 | | 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 | | 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)** | | 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 | | 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

View File

@ -61,6 +61,7 @@ class FirestoreUserDataSource @Inject constructor(
partnerId = data.getString("partnerId"), partnerId = data.getString("partnerId"),
coupleId = coupleId, coupleId = coupleId,
plan = data.getString("plan") ?: "free", plan = data.getString("plan") ?: "free",
birthDate = data.getLong("birthDate"),
createdAt = data.getLong("createdAt") ?: 0L, createdAt = data.getLong("createdAt") ?: 0L,
lastActiveAt = data.getLong("lastActiveAt") ?: 0L lastActiveAt = data.getLong("lastActiveAt") ?: 0L
) )
@ -98,6 +99,7 @@ class FirestoreUserDataSource @Inject constructor(
"partnerId" to user.partnerId, "partnerId" to user.partnerId,
"coupleId" to user.coupleId, "coupleId" to user.coupleId,
"plan" to user.plan, "plan" to user.plan,
"birthDate" to user.birthDate,
"createdAt" to user.createdAt, "createdAt" to user.createdAt,
"lastActiveAt" to user.lastActiveAt "lastActiveAt" to user.lastActiveAt
) )
@ -106,6 +108,10 @@ class FirestoreUserDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) } .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) { suspend fun updateDisplayName(uid: String, displayName: String) {
require(displayName != FieldEncryptor.LOCKED_PLACEHOLDER) { require(displayName != FieldEncryptor.LOCKED_PLACEHOLDER) {
"Refusing to persist the locked placeholder as displayName" "Refusing to persist the locked placeholder as displayName"

View File

@ -28,6 +28,9 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun updateSex(uid: String, sex: String) = override suspend fun updateSex(uid: String, sex: String) =
dataSource.updateSex(uid, sex) 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 hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
override suspend fun storeFcmToken(uid: String, token: String) = override suspend fun storeFcmToken(uid: String, token: String) =

View File

@ -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
}

View File

@ -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
}

View File

@ -9,6 +9,10 @@ data class User(
val partnerId: String? = null, val partnerId: String? = null,
val coupleId: String? = null, val coupleId: String? = null,
val plan: String = "free", 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 createdAt: Long = System.currentTimeMillis(),
val lastActiveAt: Long = System.currentTimeMillis() val lastActiveAt: Long = System.currentTimeMillis()
) )

View File

@ -11,6 +11,7 @@ interface UserRepository {
suspend fun updateDisplayName(uid: String, displayName: String) suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun updatePhotoUrl(uid: String, photoUrl: String) suspend fun updatePhotoUrl(uid: String, photoUrl: String)
suspend fun updateSex(uid: String, sex: String) suspend fun updateSex(uid: String, sex: String)
suspend fun updateBirthDate(uid: String, birthDateMillis: Long)
suspend fun hasProfile(uid: String): Boolean suspend fun hasProfile(uid: String): Boolean
suspend fun storeFcmToken(uid: String, token: String) suspend fun storeFcmToken(uid: String, token: String)
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata) suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)

View File

@ -10,7 +10,10 @@ import android.util.Log
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -190,6 +193,63 @@ fun SignUpScreen(
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signUp() }) 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)) Spacer(Modifier.height(28.dp))
Button( Button(

View File

@ -2,6 +2,8 @@ package app.closer.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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
@ -18,6 +20,8 @@ data class SignUpUiState(
val email: String = "", val email: String = "",
val password: String = "", val password: String = "",
val confirmPassword: 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 isPasswordVisible: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
@ -30,7 +34,8 @@ data class SignUpUiState(
@HiltViewModel @HiltViewModel
class SignUpViewModel @Inject constructor( class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository private val userRepository: UserRepository,
private val signupHandoff: SignupHandoff
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SignUpUiState()) 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 updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
fun updatePassword(v: String) = _uiState.update { it.copy(password = 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 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 togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
fun signUp() { fun signUp() {
val state = _uiState.value val state = _uiState.value
val pw = state.password val pw = state.password
val dob = state.birthDate
when { when {
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return } 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.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.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.
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) } _uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
@ -57,6 +67,11 @@ class SignUpViewModel @Inject constructor(
.onSuccess { .onSuccess {
// Best-effort: send a verification email. Don't block account creation if it fails. // Best-effort: send a verification email. Don't block account creation if it fails.
authRepository.sendEmailVerification() 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) } _uiState.update { it.copy(isLoading = false, success = true) }
} }
.onFailure { e -> .onFailure { e ->

View File

@ -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 // 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. // (functions/src/games/onGameSessionUpdate.ts notifyPartner) so foreground/background stay in sync.
private fun styleFor(prompt: IncomingGamePrompt): PromptStyle { 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) val game = gameDisplayName(prompt.gameType)
return when (prompt.kind) { return when (prompt.kind) {
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join") GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View") 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.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"
)
} }
} }

View File

@ -106,15 +106,20 @@ fun HowWellAnswer.isClose(other: HowWellAnswer): Boolean =
scaleValue != null && other.scaleValue != null && scaleValue != null && other.scaleValue != null &&
!isMatch(other) && abs(scaleValue - other.scaleValue) == 1 !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 { fun HowWellAnswer.displayText(config: AnswerConfig?): String = when {
selectedOptionId != null -> when (config) { 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) { is ThisOrThatAnswerConfigImpl -> when (selectedOptionId) {
config.config.optionA.id -> config.config.optionA.text config.config.optionA.id -> config.config.optionA.text
config.config.optionB.id -> config.config.optionB.text config.config.optionB.id -> config.config.optionB.text
else -> selectedOptionId else -> humanizeOptionId(selectedOptionId)
} }
else -> selectedOptionId else -> humanizeOptionId(selectedOptionId)
} }
scaleValue != null -> "$scaleValue" scaleValue != null -> "$scaleValue"
else -> "" else -> ""

View File

@ -9,6 +9,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -129,7 +130,8 @@ fun CreateProfileScreen(
containerColor = Color.Transparent, containerColor = Color.Transparent,
modifier = Modifier.background(AuthBackgroundBrush), modifier = Modifier.background(AuthBackgroundBrush),
topBar = { topBar = {
if (state.currentStep != ProfileStep.NAME) { val firstStep = if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME
if (state.currentStep != firstStep) {
TopAppBar( TopAppBar(
title = { Text("Create profile", color = AuthInk) }, title = { Text("Create profile", color = AuthInk) },
navigationIcon = { navigationIcon = {
@ -159,15 +161,42 @@ fun CreateProfileScreen(
) { ) {
Spacer(Modifier.height(48.dp)) 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(
text = "Step ${state.currentStep.ordinal + 1} of 3", text = "Step $stepNumber of $totalSteps",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = AuthMuted, color = AuthMuted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(24.dp)) 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) { when (state.currentStep) {
ProfileStep.DOB -> DobStep(
state = state,
onPickDate = openDobPicker,
onContinue = viewModel::goToNextStep
)
ProfileStep.NAME -> NameStep( ProfileStep.NAME -> NameStep(
state = state, state = state,
onNameChange = viewModel::updateDisplayName, 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 @Composable
private fun NameStep( private fun NameStep(
state: CreateProfileUiState, state: CreateProfileUiState,

View File

@ -4,6 +4,8 @@ import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.SignupHandoff
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
@ -16,6 +18,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
enum class ProfileStep { enum class ProfileStep {
DOB,
NAME, NAME,
SEX, SEX,
PHOTO PHOTO
@ -27,6 +30,11 @@ data class CreateProfileUiState(
val sex: String = "", val sex: String = "",
val photoUrl: String = "", val photoUrl: String = "",
val photoUri: String? = null, 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 isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val success: Boolean = false, val success: Boolean = false,
@ -38,7 +46,8 @@ data class CreateProfileUiState(
class CreateProfileViewModel @Inject constructor( class CreateProfileViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val storageDataSource: FirebaseStorageDataSource private val storageDataSource: FirebaseStorageDataSource,
private val signupHandoff: SignupHandoff
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(CreateProfileUiState()) private val _uiState = MutableStateFlow(CreateProfileUiState())
@ -51,12 +60,22 @@ class CreateProfileViewModel @Inject constructor(
private fun loadExistingProfile() { private fun loadExistingProfile() {
val uid = authRepository.currentUserId ?: return val uid = authRepository.currentUserId ?: return
viewModelScope.launch { 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 { _uiState.update {
it.copy( it.copy(
displayName = user.displayName, displayName = user?.displayName ?: it.displayName,
sex = user.sex, sex = user?.sex ?: it.sex,
photoUrl = user.photoUrl 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 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 setPhotoUri(uri: String?) = _uiState.update { it.copy(photoUri = uri, error = null) }
fun goBack() { fun goBack() {
val previous = when (_uiState.value.currentStep) { val state = _uiState.value
ProfileStep.NAME -> ProfileStep.NAME val previous = when (state.currentStep) {
ProfileStep.DOB -> ProfileStep.DOB
ProfileStep.NAME -> if (state.requireBirthDate) ProfileStep.DOB else ProfileStep.NAME
ProfileStep.SEX -> ProfileStep.NAME ProfileStep.SEX -> ProfileStep.NAME
ProfileStep.PHOTO -> ProfileStep.SEX ProfileStep.PHOTO -> ProfileStep.SEX
} }
@ -80,6 +103,14 @@ class CreateProfileViewModel @Inject constructor(
fun goToNextStep() { fun goToNextStep() {
val state = _uiState.value val state = _uiState.value
when (state.currentStep) { 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 -> { ProfileStep.NAME -> {
val name = state.displayName.trim() val name = state.displayName.trim()
when { when {
@ -113,6 +144,12 @@ class CreateProfileViewModel @Inject constructor(
} }
return 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 { val uid = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") } _uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") }
return return
@ -131,19 +168,28 @@ class CreateProfileViewModel @Inject constructor(
displayName = name, displayName = name,
sex = state.sex, sex = state.sex,
photoUrl = finalPhotoUrl, photoUrl = finalPhotoUrl,
birthDate = state.birthDate,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
lastActiveAt = System.currentTimeMillis() lastActiveAt = System.currentTimeMillis()
) )
if (existing == null) { 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 { } else {
userRepository.updateDisplayName(uid, name) userRepository.updateDisplayName(uid, name)
userRepository.updateSex(uid, state.sex) userRepository.updateSex(uid, state.sex)
if (finalPhotoUrl.isNotBlank()) { if (finalPhotoUrl.isNotBlank()) {
userRepository.updatePhotoUrl(uid, finalPhotoUrl) 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 { }.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) } _uiState.update { it.copy(isLoading = false, success = true) }
}.onFailure { e -> }.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") } _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }

View File

@ -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)
}
}

View File

@ -12,6 +12,32 @@ Reviewed Compose UI files under `app/src/main/java/app/closer/ui/` for responsiv
## Build Verification ## Build Verification
`./gradlew :app:compileDebugKotlin`**BUILD SUCCESSFUL** (3s) `./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 ## Per-File Findings
### `home/HomeScreen.kt` ### `home/HomeScreen.kt`

View File

@ -215,7 +215,7 @@ service cloud.firestore {
allow update: if isOwner(uid) allow update: if isOwner(uid)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([ && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId', 'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
'plan', 'createdAt', 'lastActiveAt', 'fcmToken', 'plan', 'birthDate', 'createdAt', 'lastActiveAt', 'fcmToken',
'notifPartnerAnswered', 'notifChatMessage', 'notifPartnerAnswered', 'notifChatMessage',
// Daily/streak/promotional prefs mirrored so the scheduled senders can honor them. // Daily/streak/promotional prefs mirrored so the scheduled senders can honor them.
'notifDailyReminder', 'notifStreakReminder', 'notifPromotional', 'notifDailyReminder', 'notifStreakReminder', 'notifPromotional',