Compare commits

...

26 Commits

Author SHA1 Message Date
null aaab768cb0 fix(notif): deep-link results-ready pushes to per-session results/replay screen (E-003) 2026-06-25 12:35:49 -05:00
null 3de4178fd3 qa(plan): add varied gameplay styles, exhaustive nav fuzzing, Pass G account-creation/fake-account
- Pass B: vary style of play (lengths/moods/answer types, result patterns, turn orders, exit/resume
  styles, edge inputs) to hit different code paths.
- Pass C: 'take every avenue' exhaustive nav fuzzing — tap every element, every order, rapid/repeated
  input, interrupt mid-nav, hunt dead-ends/traps.
- Pass G (new): account creation happy path + validation + duplicate/conflict + fake/malicious
  creation attempts (live D3 non-member denial, invite-code abuse, App Check, self-premium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null ee19ef3f59 qa(r4): fix phase + re-QA complete — E-003/B-004/A-OBS fixed; 0 open P0-P2
Round 4: the 2 new P2 (E-003 game-push deep-link, B-004 WaitingForPartner Join escape) + 1 new P3
(A-OBS paywall copy) from R3 are fixed, verified live, committed. Regression smoke clean (launch,
This-or-That end-to-end + B-001 auto-close, chat enc:v1 at rest, C-NAV-001 back->launcher).
Only E-OBS (P3) open — bg push channel, needs server change + user-gated functions deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null d99fa6c6ea fix(paywall): show friendly plan-load error, not raw SDK message (A-OBS)
The paywall ErrorState rendered uiState.error verbatim, surfacing developer-facing billing/RC SDK
text ('There was a credentials issue. Check the underlying error for more details.') to users.
Now always shows friendly copy. Verified live: free user -> paywall -> 'We couldn't load
subscription options right now. Check your connection and tap to try again.' (no raw error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null f1549c642c fix(games): add 'Join the game' escape to WaitingForPartner screen (B-004)
The generic WaitingForPartner screen only exited when the session became null, so a partner who
landed there for an async game they could actually play (every current game is async — both play
on their own device) was stuck waiting forever, recoverable only via Back to Games. Now the screen
resolves the active session's game route and offers a primary 'Join the game' action that drops the
user into the game (which auto-joins the session). Deterministic repro: QA starts How Well, Sam
opens a different game -> one-game lock routes Sam to WaitingForPartner -> 'Join the game' -> How
Well guess intro. Verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null 1b9d8cf8dc fix(notif): game pushes deep-link into the waiting game, not the Play hub (E-003)
partner_started_game / partner_completed_part now route to the specific game route
(gameRouteForType(payload.gameType)) so the game screen auto-joins the couple's active
session — fulfilling the 'Tap to join!' promise. Server already sends game_type in the FCM
data; client now parses it (AppMessagingService + MainActivity) into PartnerNotificationPayload
and routeFor maps it. game_results_ready stays on the hub pending a server change to also send
game_session_id (completed sessions aren't returned by getActiveSession, so the plain game route
would show setup). Verified live: backgrounded partner tapped the start-game push -> opened This
or That at 1/5 (joined), not the hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null a2b38485b1 docs(seed): align type names with repo schema — single_choice, this_or_that, answer config 2026-06-25 12:35:49 -05:00
null d1026c7312 docs(seed): split question guide into three documents — content guide, schema, rewrite plan 2026-06-25 12:35:49 -05:00
null 0c8586fa9e qa(r3): Round 3 full re-QA (A-F) COMPLETE — 12 fixes hold; 5 new issues logged
Pass F: offline (cache renders, no crash), rotation (state preserved), process-death (~6 clean
restarts), concurrency (simultaneous game play synced) all OK.
RESULT: all 12 prior fixes re-verified holding live; no P0/P1, no security/encryption findings.
New: 2xP2 (B-004 intermittent guesser-stuck; E-003 game notif -> Play hub not the game),
2xP3 (A-OBS paywall copy [env]; E-OBS bg fallback channel); C-OBS resolved (debug menu is gated).
Not yet flawless (2 open P2) -> fix phase + Round 4 re-QA. Baseline restored (both free, 0 active).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null 682a9d8ea7 qa(r3): Pass E live notif tests — chat_message full chain OK; log E-003 + E-OBS
chat_message: deliver (bg) + content-free + tap deep-links to exact conversation with content loaded.
partner_started_game: deliver + content-free OK; tap -> generic Play hub (not the game).
E-003 (P2): game pushes routeFor->AppRoute.PLAY (not the specific game/results) despite 'Tap to join!';
  extend B-002's game-route resolver to notifications.
E-OBS (P3): backgrounded pushes use fcm_fallback_notification_channel, bypassing code-defined
  CHANNEL_GAMES/chat channels (server sends notification not data-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null fa6d80602a docs(brand): add asset-system.md, cross-link from visual-identity.md 2026-06-25 12:35:49 -05:00
null afd81e8120 qa(r3): Pass C visual sweep + Pass D security re-audit clean
Pass C: ~14 screen-types in dark (Home, Play, all 7 games, paywall, Settings+Subscription+Appearance,
Today, Messages inbox, Conversation) render clean, no FATAL, no new contrast issues, 0 enc:v1 leaked
to UI. C-DS-001 holds. C-OBS: debug menu entries (verify BuildConfig.DEBUG-gated). Remaining standard
list/detail screens deferred (nav-drift).
Pass D: deployed rules re-audited (B-001 + D-001 fixes present, hasPremium/entitlements server-only,
ciphertext enforced, no catch-all); at-rest chat text + preview = enc:v1. D3 live deferred (3rd acct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null c7140b1e10 qa(r3): Pass A + Pass B fully re-verified live
Pass A: neither->paywall, partner->couple-shared unlock, self->unlock, A-003 badges both
directions. New A-OBS (P3): paywall plan-load shows raw 'credentials issue' (env: no RC sandbox).
Pass B: all 7 game areas played; B-001 holds across 4 async types (auto-complete); B-002 clean
case works; B-003 + C-DS-001 hold; Date Match deck + Wheel + How Well + Desire Sync + ToT all PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null ddecfabcd9 qa(r3): How Well verified (5/5, B-001 holds); log B-004 intermittent guesser-stuck on WaitingForPartner
How Well two-device playthrough PASS x2 (subject+guesser, reveal correct, session auto-closes).
B-002 clean case works (Play now -> guess INTRO when subject done).
NEW B-004 (P2, intermittent): guesser can land on generic WaitingForPartnerScreen for How Well
during a rapid game-to-game transition and get stuck (screen only exits on session end).
Not reproduced in clean case; escalate to P1 if deterministic. Report-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null 185d27b921 qa(r3): re-QA round 3 — nav + premium + Desire Sync/This-or-That fixes re-verified live
Round 3 full re-QA in progress. Re-verified LIVE on both emulators (build ce7fc2e):
C-NAV-001 (back->launcher), C-CC-001 (single header), back-stack clean,
A-001 couple-shared (Sam free unlocks Desire Sync+Memory Lane), A-003 (0 badges),
D-001 (capsules load, no PERMISSION_DENIED), B-001 (2 consecutive games auto-close),
B-002 (Home Play-now resumes exact game), B-003 (coherent counts), C-DS-001 (dark contrast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:35:49 -05:00
null 6eeb86320b qa(fix-phase): all P0-P3 FIXED (P3x4: A-003/B-003/E-002/F-OBS); severity board all clear 2026-06-25 10:14:18 -05:00
null 8e08823e83 fix(memorylane): propagate snapshot-listener errors so the screen doesn't hang (F-OBS P3)
observeCapsules swallowed listener errors (return@), so on PERMISSION_DENIED the flow never
emitted or closed and Memory Lane hung on its loading heart forever. Now close(err)s the
flow -> the ViewModel's existing onFailure -> ERROR state with Retry. (Root cause that
masked D-001.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:17 -05:00
null 46010508a9 fix(notifications): route partner_left/partner_deleted_account to Home (E-002 P3)
Added PARTNER_UNPAIRED type for the two real 'you are unpaired' pushes -> Home, where the
now-solo user gets the Invite CTA (matches body 'Tap to create a new invite'). Documented
that invite_created (server audit log, read:true) and spki (a crypto key-format string in
the RevenueCat webhook, not a notification) are false positives needing no routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:16 -05:00
null f63188d97a fix(desiresync): clearer privacy counts on reveal (B-003 P3)
Per-person tiles showed '$total private' (e.g. '5 private'), contradicting the caption's
'N kept private' (e.g. '2 kept private'). Tiles now read just 'Private' (your individual
answers always stay private); the caption keeps the real shared/kept breakdown. Verified:
'You: Private / Sam: Private' + 'N shared, M kept private', no contradiction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:15 -05:00
null f8ae15a41b fix(play): hide Premium badge on Desire Sync/Memory Lane cards when couple has premium (A-003 P3)
Threaded showPremiumBadge=!hasPremium into DesireSyncCard/MemoryLaneCard and gated the
lock badge behind it. The feature was already accessible (A-001) — only the static badge
was misleading. Verified: with couple premium the Play hub shows no Premium badge on them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:14 -05:00
null 0c67c505be qa(fix-phase): mark B-002/C-CC-001/C-DS-001 (P2) FIXED+verified; P0-P2 all clear, P3x4 remain 2026-06-25 09:58:27 -05:00
null a94f44d3ec fix(home): 'Play now' resumes the waiting game, not the generic hub (B-002 P2)
Resolve the active session's gameType to its resume route (gameRouteFor) and carry it on
HomeAction.gameRoute / HomeUiState.waitingGameRoute; HomeActionTarget.Game now navigates
there (fallback Play hub). Each game screen auto-joins the couple's active session on open,
so the Home 'Play now' CTA drops the user straight into the actual waiting game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:26 -05:00
null 1fe4dea9c1 fix(desiresync): theme-aware reveal text for dark mode (C-DS-001 P2)
DesireMatchCard used a hardcoded dark plum (Color(0xFF3D1F2E)) for the shared-desire
text -> readable on the light card in light mode, but dim/low-contrast on the dark-tinted
card in dark mode. Switched to MaterialTheme.colorScheme.onSurface so it adapts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:25 -05:00
null edc00d2a5f fix(nav): drop Connection Challenges from shellBackRoutes (C-CC-001 P2)
The screen renders its own header (title + 'Pick a series…' subtitle + back) for both
the pick and active views, so the nav-scaffold app bar drew a SECOND identical header +
back arrow on top. Removed it from shellBackRoutes -> single header, single back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:24 -05:00
null 1108a57c4a qa(fix-phase): mark B-001 + C-NAV-001 (both P1) FIXED+verified; severity P1 0 open/4 fixed 2026-06-25 09:37:38 -05:00
null a82c43ad90 fix(rules): allow completedByUsers on session update so finished games close (B-001 P1)
The sessions allow-update rule required affectedKeys().hasOnly(['status','completedAt']),
but the async-game completion path (markUserComplete) always writes completedByUsers, so
every 'I reached results' write was denied and the session stayed active forever -> the
couple was locked out of starting any new game (only the destructive 'End their game'
worked, since abandonSession only diffs status/completedAt). Rule now permits
['status','completedAt','completedByUsers'], lets any couple member record completion
progress, keeps startedByUserId immutable and status monotonic (active->completed).
Deployed + verified live: both finish a game -> session auto-completes (completedByUsers
=[both]) -> next game starts immediately (no 'Waiting for partner' block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:37:37 -05:00
28 changed files with 1540 additions and 823 deletions

View File

@ -1,7 +1,7 @@
# Claude QA Coverage Matrix
> Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position.
> Round 1 in progress.
> **Round 4 (fix phase + re-QA) COMPLETE 2026-06-25, build `6f6f76a`:** E-003 + B-004 (P2) + A-OBS (P3) FIXED + verified live + committed. 0 open P0P2. Only E-OBS (P3, bg push channel) open — deferred (server change + user-gated functions deploy). Regression smoke clean (launch, This-or-That end-to-end + B-001 auto-close, chat `enc:v1:` at rest, C-NAV-001 back→launcher).
## Pass A — Couple-shared premium (states: neither / partner-only / self)
| Feature | neither→locked | partner→both unlock | self→unlock | Status |
@ -16,6 +16,7 @@
| Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) |
Pass A: **complete** (1 systemic P1). **A-001 FIXED** (e8892a9) — couple-shared everywhere; re-verify each feature in re-QA. New cosmetic A-003 (P3, badge). Subscription screen by-design.
**R3 re-verified LIVE (2026-06-25):** neither→paywall ("Go deeper together"), partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane), self→unlock; A-003 badges hidden under premium / shown when free (count 0↔2). New A-OBS (P3): paywall plan-load shows raw "credentials issue" error (env: no RevenueCat sandbox).
## Pass B — Games lifecycle (start / play / finish + results)
**RESTARTED 2026-06-24 (R2-B2): full re-run from game #1 with the PLAY-AS-THE-USER mindset** (navigate only via the
@ -43,12 +44,29 @@ reveal/results), not just launched.** All rows above are currently `launch ok /
still owed for every game** in Round 2 (premium games need a premium toggle). A launch-only row counts as `partial`, not `pass`.
## Pass C — Visual (light + dark), all ~50 routes
_todo — enumerate from AppRoute.kt; 5554=Dark, 5556=Light. Main tabs pass; deep/stateful screens owed._
**Also owed:** navigation from EVERY entry point (each screen via all its links) + back-stack / "double-back"
(system back + in-app back to correct place from each entry; no dead-ends, no exit surprise, no two-back/duplicate stack).
**R3 (2026-06-25):** ~14 screen-types swept in Dark (5554), several in Light (5556 during A/B) — all render clean,
readable, no FATAL, no new dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play
hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question
(+answer detail), Messages inbox, Conversation (image+voice+text+reaction). C-DS-001 dark-contrast fix holds.
**Back-stack ✅** deep→hub→Home→launcher clean (no double-back; C-NAV-001 holds). C-OBS resolved (debug menu gated).
_Deferred (nav-drift; standard list/detail, lower-risk): Question Packs detail, Bucket List, Past Games, Wheel History,
Answer Reveal (sealed), Date Builder/Plan Date, fresh-account auth/onboarding/pairing._
## Pass D — Security & Encryption (D1D6)
_todo_
**R3:** D2 deployed rules re-audited ✅ (B-001 sessions + D-001 capsules/challenges fixes present; hasPremium +
entitlements server-only; ciphertext enforced; no catch-all). D1 at-rest ✅ (chat text + lastMessagePreview =
`enc:v1:`; how_well answers + capsules = `enc:v1:`). D4/D5/D6 unchanged since R1 (code identical) → hold.
**D3 live non-member: deferred** (needs a 3rd fresh account; only 2 emulators, both couple members; rule logic
statically member-scoped). No P0/P1 security findings.
## Pass E — Notifications (17 types × {foreground, background, killed} + tap-to-open)
_todo_
**R3 live:** FCM tokens valid for both. **chat_message ✅ full chain** (bg deliver + content-free + tap→exact
conversation w/ content). **partner_started_game**: bg deliver + content-free ✅; tap→Play hub (not the game) =
**E-003 (P2)**. **E-OBS (P3)**: bg pushes use fcm_fallback channel. date_match live-verified R2-B2. E-001/E-002 fixes
present in code. Full 17×{fg/bg/killed} matrix not exhaustively run; routing centralized + code-verified for the rest.
## Pass F — Resilience / lifecycle / concurrency / time
**R3:** offline (airplane mode) → Today renders from cache, no crash ✅; rotation/config-change → landscape renders,
state preserved, no crash ✅; process-death/restore → ~6 cold restarts all clean to Home (auth persists) ✅;
concurrency → both devices played games simultaneously, sessions synced + B-001 auto-complete on concurrent finish ✅.
Time-gated content (capsule "Opens in 29 days", challenge day-gating) can't be time-traveled — noted.

View File

@ -154,6 +154,19 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
intermediate screen and interaction works (selections register, progress advances, both-answered gating,
reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play.
- The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both.
- **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a
real couple would play each game, because different inputs hit different code paths:
- **Different option/length/mood choices** — every round length (5/10/15), every mood/category, the "All topics"/
shuffle option, and **each distinct answer type** (A/B, Yes/No, True/False, 15 scale, multi-select, free-text).
- **Different answer *patterns* that change the result** — all-match vs all-mismatch vs partial; both-yes vs both-no
vs split (so reveals show "shared", "all private", "0 matches", "perfect/zero score" — verify each renders right).
- **Different turn orders / who-starts** — partner A starts vs partner B starts; the guesser opens before vs after
the subject finishes; both open simultaneously (race); one device much slower than the other.
- **Different exit/resume styles** — finish normally; quit mid-game; background mid-game then resume; cold-kill
mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games
back-to-back, and a *different* game type immediately after.
- **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next,
spamming the start button, tapping during the reveal animation. None should crash, duplicate, or desync.
- Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean.
- Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**.
- **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully.
@ -175,6 +188,21 @@ Account); Paywall; Your Progress/Activity; Recovery.
opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game
from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today
AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug.
- **TAKE EVERY AVENUE (exhaustive nav fuzzing — actively hunt for nav bugs, don't just walk the happy path):** treat
navigation as something to *break*. On every screen, **tap every interactive element** — each button, card, row,
icon, chip, link, tab, header back-arrow, system back, and any "see all / history / edit / manage" affordance — and
follow where it goes. Then try the *combinations and sequences* a curious user hits:
- **Every order:** switch bottom tabs in many orders, mid-flow (open a game, jump to Messages, come back); enter a
deep screen then tab away then back; open A→B→C then back-back-back.
- **Rapid / repeated input:** double- and triple-tap navigation targets (especially "open game", "Play now",
"Create/Start session", notification taps) to surface double-push/duplicate-screen/stale-route bugs (cf. B-004).
- **Interrupt mid-navigation:** background/rotate/lock during a transition; tap a notification while already on that
screen, on a different screen, and while logged-out/unpaired; cold-start straight onto a deep link.
- **Dead-ends & traps:** from *every* screen confirm there's always a way out (back/close/home) — no screen that
strands the user, needs two backs, exits the app unexpectedly, loops, or lands blank. Re-check the asymmetric-game
waiting screens, replay/results screens, and paywall specifically.
- Log **every** wrong/duplicate/dead destination with the exact tap sequence to reproduce. Wrong/double-back or
dead-end = **P2** (P1 if it traps the user or loses their progress).
- **Back-stack / "double back":** from every entry point, **system back AND the in-app back arrow** return to the
correct previous screen — no dead-ends, no exiting the app unexpectedly, and **no screen that requires pressing
back twice** (duplicate/stacked destinations on the back stack = bug). Bottom-tab reselection and deep-link/
@ -203,6 +231,31 @@ Account); Paywall; Your Progress/Activity; Recovery.
couple — migration completes without exposing plaintext or losing/garbling old content, and a half-migrated couple
is safe (no mixed read failures, no downgrade). This is the riskiest data path for existing users.
### Pass G — Account creation, validation & fake-account abuse (MANDATORY — both the happy path AND the attacks)
Cover **every account-creation avenue a real user takes** and **every fake/abusive creation attempt an attacker would
try.** Use throwaway test accounts (sign-out → fresh sign-up; never `pm clear`). Report-first like every pass.
- **Real creation flows (happy path + validation):** sign-up (email/password and any social/anonymous path), profile
creation, and pairing — both **create-invite** and **accept-invite** sides. Verify field validation (invalid/empty
email, weak/short password, mismatched confirm, name length/emoji/unicode), the **error copy is friendly** (no raw
SDK/Firebase error leaking — cf. A-OBS), loading/disabled states, and that a brand-new unpaired account lands on the
correct "create or accept invite" home (not a broken/blank or paired view).
- **Duplicate / conflicting creation:** sign up with an **already-registered email** (clear "already in use", no crash,
offer sign-in); create a second account while one is signed in; re-run onboarding after completing it; accept an
invite while **already paired** (must be rejected cleanly); two devices accepting the **same invite** (single-use —
the second must fail gracefully).
- **Fake / malicious creation attempts (security — expect DENY, never crash or leak):** create an account that is
**NOT a member** of the test couple and attempt every cross-couple action (read messages/answers/dates/entitlements,
write to the couple, self-grant `premium`/`hasPremium`, join/hijack pairing with a guessed/expired/reused invite
code) — all must be **denied by rules** (this is the live execution of **D3**). Probe **invite-code abuse**: replay a
used code, use an expired code, brute-force/guess attempts (CSPRNG entropy + single-use + expiry must hold). Probe
**App Check**: a request without a valid token is rejected. Confirm a malformed/forged sign-up can't bypass profile
or membership requirements. **Any successful unauthorized create/read/write = P0.**
- **Account lifecycle around creation:** sign-out → sign-in (state restores, no stale couple); **delete account** then
re-create with the same email (clean slate, partner notified/unpaired); an unpaired/just-created account tapping a
stale notification or deep link is handled gracefully (no crash, sane landing).
- **Done = every creation avenue exercised** (happy + duplicate + malicious) with each attack **denied** and each happy
path validated end-to-end; findings filed with exact repro.
### Pass E — Notifications (every type delivers, deep-links, leaks nothing)
For each: trigger fires → delivered to the **right partner (never self)** → in **foreground/background/killed**
correct channel + copy with **no private content****tap opens exactly the right item** (loaded, not generic Home/

View File

@ -1,6 +1,7 @@
# Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 2 | Pass B (R2-B2, play-as-the-user) COMPLETE — all 7 games played one full time through on both devices, user-nav. ALL 7 PASS gameplay-wise (This or That, How Well, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, Date Match). Sam reverted to free (baseline). Findings this pass: B-001 (P1, finished session never closes→blocks next game), C-NAV-001 (P1, back from Home resurfaces onboarding/auth), B-002 (P2, Home "Play now" lands on hub), C-CC-001 (P2, dup header/double-back), C-DS-001 (P2, dark contrast), B-003 (P3, confusing Desire Sync counts). NEXT ACTION: Pass C deep/stateful screens both themes + nav-from-every-entry + back-stack (reproduce/expand C-NAV-001, C-CC-001); then live notification matrix (date_match already seen live ✅); D3 non-member; Pass F (incl. F-OBS). THEN fix phase by severity (P1 B-001 + C-NAV-001 first).**
> **RUN-STATE: Round 4 (fix phase + re-QA) COMPLETE — 2026-06-25, build HEAD `6f6f76a` on BOTH emulators. Fixed + verified LIVE the 3 tractable R3 findings: E-003 (game pushes now deep-link into the game, not the Play hub — tapped start-game push → This or That 1/5, joined), B-004 (WaitingForPartner now has a "Join the game" escape → How Well guess flow; deterministic repro fixed), A-OBS (paywall shows friendly copy, no raw "credentials issue"). Round-4 regression smoke clean: cold-launch no crash, This or That end-to-end → session auto-closes (B-001 holds), chat text `enc:v1:` at rest, C-NAV-001 back→launcher. ONLY REMAINING: E-OBS (P3) — backgrounded pushes use the FCM fallback channel; fix is server-side (set `android.notification.channel_id` across functions senders) + a functions deploy, which is USER-GATED → deferred for authorization. So: 0 open P0P2; 1 open P3 (E-OBS, deploy-gated). Baseline restored: both free, 0 active sessions.**
> _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._
> Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games.
> **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed.
> R2-1 DONE: A-001 couple-shared re-verified live (Desire Sync/Memory Lane/Wheel enter when partner premium; free→paywall). **D-001 (P1) FIXED+DEPLOYED** (capsules/challenges rules; Memory Lane + Connection Challenges now load). Sam reverted to free (baseline).
@ -14,13 +15,22 @@ _(Prior games/notifications QA from 2026-06-24 was completed + verified; superse
---
## Severity summary (current — R1 fixed + R2 findings)
| Severity | Open | Fixed |
## Severity summary (current — after Round 4 fix phase + re-QA)
| Severity | Open | Fixed (verified live) |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 2 | 2 |
| P2 | 3 | 1 |
| P3 | 4 | 0 |
| P1 | 0 | 4 |
| P2 | **0** | **6** |
| P3 | **1** (E-OBS — deploy-gated) | **5** |
**Round 4 result:** the 2 new P2 (E-003, B-004) + 1 new P3 (A-OBS) from R3 are **FIXED + verified live + committed**. **0 open P0P2.** Only E-OBS (P3) remains, deferred because its fix needs a user-gated functions deploy. Combined with R3 (all 12 earlier fixes hold; Pass D clean; E2EE at-rest + UI intact), the app has **no open crash/data/security/premium/nav-dead-end issues** — the one remaining item is notification-channel polish requiring a backend deploy.
**R3/R4 issue dispositions:**
- **E-003 (P2) — FIXED** `23c9923`: game pushes (`partner_started_game`/`partner_completed_part`) now route via `gameRouteForType(payload.gameType)` into the specific game (auto-joins the active session), not the Play hub. Server already sends `game_type`; client parses it in AppMessagingService + MainActivity. `game_results_ready` stays on the hub pending a server change to also send `game_session_id` (documented). **Verified live:** tapped start-game push → This or That 1/5 (joined).
- **B-004 (P2) — FIXED** `da7fc74`: `WaitingForPartnerScreen` now resolves the active session's game route and offers a primary **"Join the game"** action (every game is async/joinable), so the partner is never stuck. **Verified live** via deterministic repro: QA started How Well → Sam opened This or That → WaitingForPartner → "Join the game" → How Well guess intro.
- **A-OBS (P3) — FIXED** `6f6f76a`: paywall ErrorState no longer renders the raw billing/RC SDK message; shows friendly "We couldn't load subscription options right now…". **Verified live** (raw "credentials issue" gone).
- **E-OBS (P3) — OPEN, deferred:** backgrounded pushes use `fcm_fallback_notification_channel`, bypassing code-defined channels. Fix is server-side (set `android.notification.channel_id` on every FCM send across functions, or send data-only + build client-side) + a **functions deploy (user-gated)**. Cannot verify without deploying.
- **C-OBS (RESOLVED, not a bug):** Settings "Art preview/Paired home (debug)" entries ARE `BuildConfig.DEBUG`-gated (SettingsScreen.kt:469) — won't ship in release.
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2).
**Round 2 (Pass B play-as-user restart) new/changed:** **B-001** escalated **P3→P1** (a finished game never closes its
@ -32,6 +42,63 @@ game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informa
---
## Round 3 re-QA log (2026-06-25, build `ce7fc2e`) — fix regression + deferred coverage
**Fixes re-verified LIVE this round:**
- **C-NAV-001 ✅** — cold start (logged in) → Home → system Back → focus = `NexusLauncherActivity` (app exits). No onboarding resurfacing.
- **C-CC-001 ✅** — Play hub → Connection Challenges (active Gratitude Week) → single header (`Back` desc count = 1), no duplicate title.
- **Back-stack ✅** — clean cold-start hierarchy: deep screen (challenge) → Back → Play hub → Back → Home → Back → launcher. No double-back, no dead-ends. (Earlier "double-back" suspicion was warm nav-state restoration of the last Play-tab destination, not a real defect — does not reproduce from cold start.)
- **A-001 ✅ (couple-shared)** — QA set premium, Sam left free. Sam (5556, partner-premium) Play hub → Desire Sync opens to "How long?" setup (no paywall) + Memory Lane opens (sealed capsule shown). QA (5554, self-premium) likewise unlocked.
- **A-003 ✅** — Play hub shows **0 "Premium" badges** on both 5554 (self-prem) and 5556 (partner-prem couple-shared).
- **D-001 ✅** — Sam opened Memory Lane → capsule list renders, **0 PERMISSION_DENIED** in logcat (capsules rule holds; no hung heart → F-OBS path healthy).
**Desire Sync two-device playthrough (premium ON; QA started, Sam joined):**
- **B-001 ✅** — both answered all 5 → admin read shows **active=0** (session auto-flipped to completed, no "End their game" needed). Core loop intact.
- **B-003 ✅** — reveal counts fully coherent: "3 shared desires / 2 answers stayed private" + tiles "You: Private / partner: Private" + caption "3 shared, 2 kept private". No contradicting "5 private".
- **C-DS-001 ✅** — 5554 (dark) reveal "You both said yes to" list renders crisp **white high-contrast** text (old dim muted-pink gone); 5556 (light) black-on-light. Both readable.
- Gameplay PASS — privacy logic correct (QA T,Y,Y,Y,T vs Sam T,Y,N,Y,F → exactly the 3 mutual-affirmative shown, 2 mismatches hidden), reveals match on both, no crash. Sam (free) joined QA's session = couple-shared join works.
**This or That two-device playthrough (immediately after Desire Sync — 2nd consecutive game):**
- **B-002 ✅** — QA started This or That → Sam's Home showed "Game waiting / Your partner is waiting to play" → Sam tapped **"Play now"** → landed directly in This or That **1/5** (the exact waiting game), NOT the generic hub.
- **B-001 ✅ (2nd consecutive game)** — both answered 5/5 → admin **active=0** again. Proven a couple can play two games back-to-back with no dangling/blocking session. Core loop solid.
- Gameplay PASS — both picked A on all 5 → Sam results "5/5 in sync — Two peas in a pod, matched on 5 of 5" with correct per-Q breakdown; consistent on both; **0 FATAL** in logcat.
**How Well Do You Know Me two-device playthrough (QA subject, Sam guesser):**
- Gameplay PASS (×2) — QA answered 5 about self (incl. two 1-5 scale Qs); Sam guessed via Play hub → reveal "5 of 5 — Perfect read / You guessed 5 of 5 about QA" with all-correct breakdown on both; scoring accurate; no crash.
- **B-001 ✅ (3rd game type)** — session auto-completed (active=0) once both submitted.
- **B-002 (clean case) ✅** — with the subject (QA) DONE first, Sam's Home "Play now" → How Well guess INTRO ("I'm ready") correctly (route gameRouteFor(how_well)=HOW_WELL → HowWellScreen.joinSession → INTRO).
- **B-004 (NEW, P2, intermittent) — guesser can get stuck on the generic "Waiting for Partner" screen for How Well.** Observed once: during a rapid This-or-That→How-Well transition (Sam had just finished This or That; QA then started How Well and was still mid-answer), Sam tapped Home "Play now" and landed on the generic `WaitingForPartnerScreen` ("Waiting for QA / QA is playing a How Well game"), which **only exits when the session ends** (its VM navigates away only on session==null) — it never routes the guesser into the guess flow. So the guesser is trapped there (recoverable only via "Back to Games" / "End their game"; re-entering How Well via the Play hub then works). NOT reproduced in the clean subject-done case (Play now → INTRO worked). Likely a stale `waitingGameRoute`/transition race sending the guesser to a non-How-Well game screen (which sees an active how_well session of a "different type" → WaitingForPartner) or directly to WAITING_FOR_PARTNER. **Repro is timing-dependent — needs a deterministic trigger; if it proves deterministic for "guesser taps Play now while subject is mid-answer", escalate to P1 (traps the user).** Report-only (logged, not fixed mid-pass).
**Spin the Wheel two-device playthrough:** QA spun → "Emotional Intimacy" (10 Qs) → Start session → Sam joined QA's active wheel session (1/10). Both answered all 10 → reveal "Complete / Here's how you each answered / Emotional Intimacy" with per-Q You/Sam breakdown on both; **session auto-closed (active=0) → B-001 holds (4th game type: desire_sync/this_or_that/how_well/wheel all auto-complete)**; **0 FATAL**. (Some rows "Skipped" = free-text prompts the automated driver doesn't type; not an app bug.)
**D-001/Memory Lane (re-confirmed R3):** Sam (partner-prem) opened Memory Lane → existing sealed capsule "Opens in 29 days" renders, no hung heart, 0 PERMISSION_DENIED.
**Connection Challenges (re-confirmed R3):** active "Gratitude Week / Day 2 of 7" loads with single header (C-CC-001), back returns to Play hub cleanly.
**Date Match (R3):** opens (single header, "Swiping with Sam"), deck advances through cards (Sunrise hike → Overnight camping…), premium date ideas accessible under couple premium, 3 existing matches badge, no FATAL. (Full mutual-match + live push verified R2-B2.)
**Pass A neither→locked ✅** — premium toggled OFF, both free → Play hub re-shows Premium badges (Memory Lane 🔒, Past Games 🔒; count back to 2, A-003 gating confirmed BOTH directions) → tapping Desire Sync opens the **paywall** ("Go deeper together / Unlock everything Closer has built for couples", What's-included list, Continue/Restore) — gate correctly blocks free users (does NOT enter the game). **Pass A fully re-verified: neither→paywall, partner→couple-shared unlock, self→unlock, A-003 badges both directions.**
- **A-OBS (P3/observe, likely env-only):** the paywall's plan list fails with "**Couldn't load plans — There was a credentials issue. Check the underlying error for more details.**" + disabled Continue. Expected in this emulator (no RevenueCat/Play-billing sandbox), so the gate itself is fine; but that **raw developer-ish error copy is user-facing** — in prod, a plan-load failure should show a friendlier message. Flag for copy review (not a gate bug).
**Pass B (R3) — all 7 game areas covered:** Desire Sync ✅, This or That ✅, How Well ✅ (+B-004 logged), Spin the Wheel ✅, Date Match ✅, Connection Challenges ✅ (loads/single-header/active Day 2), Memory Lane ✅ (loads/sealed capsule). **B-001 confirmed across 4 async game types (auto-complete, no stuck session). B-002 works (clean case). All fixes (B-001/B-002/B-003/C-DS-001) hold.**
**Pass C (R3) — deep-screen visual sweep (5554=Dark primary; several seen in Light on 5556 during A/B):**
Verified render cleanly, readable, **no FATAL, no new dark-mode contrast issues** — Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, **Settings** (+ **Subscription** "One subscription for both partners — no double billing", + **Appearance** Theme radios), **Today**/daily-question (incl. answer detail "Save privately / Discuss"), **Messages inbox** (avatars/timestamps), **Conversation** (image + voice + text msgs, ❤️ reaction, "Seen", input bar). **E2EE UI check: 0 `enc:v1` ciphertext leaked into the conversation UI** (messages decrypt for the user). C-DS-001 dark-contrast fix holds.
- **C-OBS (P3/observe):** Settings shows "**Art preview (debug)**" + "**Paired home (debug)**" entries — debug-only menu items (expected in this debug build; confirm they're `BuildConfig.DEBUG`-gated so they don't ship in release).
- _Deferred (nav-drift made per-screen capture slow; standard list/detail screens, lower risk): Question Packs detail, Bucket List, Past Games, Wheel History, Answer Reveal (sealed), Date Builder/Plan Date, and a fresh-account pass on auth/onboarding/pairing. No issues seen on the ~14 screen-types reached; the deferred set is standard Compose list/detail using the same theme tokens already verified._
**Pass D (R3) — re-audit clean, no P0/P1:**
- **D2 rules (deployed) re-audited ✓** — no catch-all `match /{document=**}`, no blanket `if true`; **sessions update (B-001 fix present)**: only `['status','completedAt','completedByUsers']`, `startedByUserId` immutable, status monotonic active→completed; **hasPremium server-only** (client write+diff blocked L172/174); **entitlements** owner+partner read (couple-shared) / write server-only; **capsules (D-001)** member-read + ciphertext-enforced (isCiphertext title+content) + authorId-bound + key allowlist + coupleEncryptionEnabled; **challenges (D-001)** member-read + progress-only writes.
- **D1 at-rest ✓** — live admin read: chat `text`=`enc:v1:`, `lastMessagePreview`=`enc:v1:` (media-only msg has no text field = no plaintext); how_well answers + Memory Lane capsules = `enc:v1:` (Pass B). **No plaintext content leak.** UI check: 0 `enc:v1:` rendered to the user (Pass C conversation).
- D4 (wrapped couple key / KDF), D5 (App Check, gitignored SA JSONs, allowBackup=false), D6 (analytics metadata-only) unchanged since Round 1 — code identical, still hold.
- **D3 live non-member negative test: still deferred** — needs a 3rd fresh account not in the couple (only 2 emulators, both members; signing one out risks the App Check debug token + couple state). Rule logic is statically member-scoped (`isCouplesMember` gate on every couple subcollection) — denial holds by construction.
**Pass E (R3) — live notification tests (both FCM tokens valid, len=142):**
- **chat_message ✅ FULL CHAIN** — Sam backgrounded; QA sent a message → Sam received push **title "QA sent a message" / body "Tap to read and reply."** (content-free ✓, actual text NOT in payload → D6 holds) → **tapped → opened the exact conversation with the new message loaded** (deep-link ✓, background→cold path).
- **partner_started_game** — Sam backgrounded; QA started This or That → Sam received **"QA is playing / QA has started a game. Tap to join!"** (delivery ✓, content-free ✓). **BUT tap → landed on the generic Play hub, NOT the game.**
- **E-003 (NEW, P2) — game notifications deep-link to the generic Play hub, not the specific game/results.** Code: `PARTNER_STARTED_GAME`/`GAME_RESULTS_READY`/`PARTNER_COMPLETED_PART` all `routeFor → AppRoute.PLAY` (PartnerNotificationManager L270-272). The body says "Tap to **join**!" but the user lands on the hub and must find+tap the game card themselves (tapping it does then join the session, per B-002). Same gap B-002 fixed for the Home "Play now" card — never extended to notifications. Plan's Pass E wants "the **specific item**, not just the right tab" (strictly P1; rated P2 since it lands on the right tab and is recoverable). **Fix:** route game pushes through the active-session→game-route resolver (like HomeViewModel.gameRouteFor) so the deep-link joins the game. Report-only.
- **E-OBS (NEW, P3) — backgrounded pushes use `fcm_fallback_notification_channel`, not their code-defined channels.** The delivered chat + game pushes both landed on `fcm_fallback_notification_channel` even though the code assigns `CHANNEL_GAMES`/chat channels (PartnerNotificationManager L185). Means the server sends FCM "notification" (not data-only) messages, so the system auto-displays them on the fallback channel when backgrounded — bypassing the app's channel importance/sound and the per-category toggle (users can't mute just "Games"). **Fix:** send data-only FCM + build the notification client-side with the right channel, or set `android.notification.channel_id` in the FCM payload. Report-only.
- Foundations ✅ — both users registered FCM tokens; routing centralized in `PartnerNotificationType`; E-001 (daily_question/challenge_day_ready) + E-002 (partner_left→HOME) fixes present in code. date_match push live-verified R2-B2.
- _Full 17×{fg/bg/killed} matrix not exhaustively run; chat_message + partner_started_game live-verified this round (deliver+content-free; chat deep-link ✓, game deep-link → E-003). Remaining types: routing code-verified._
_Still to verify this round: edges (re-open completed / leave mid-game), Pass F._
## Pass A — Couple-shared premium ✅ pass complete
**Target:** if either partner is premium, all premium features unlock for both.
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
@ -39,7 +106,7 @@ game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informa
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | Open (deferred — thread hasPremium into the card composables) |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | **FIXED** — added `showPremiumBadge` param to `DesireSyncCard`/`MemoryLaneCard`, gated the badge behind it, pass `!hasPremium` from the Play hub. **Verified LIVE:** with couple premium, Play hub shows 0 "Premium" badges on those cards (both cards present, no lock). |
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
@ -47,10 +114,10 @@ game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informa
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| B-001 | Games / sessions | couples/{id}/sessions | **P1** (was P3→P2→P1) | **A finished game NEVER closes its session — there is no normal-user path to complete it — so every game leaves a dangling `status=active` session that blocks ALL other games.** Definitively proven on the R2-B2 restart: played This or That fully through on BOTH devices → both reached the results screen → **BOTH tapped the intended "Back to Play" button** → both navigated back to the Play hub, **but the session stayed `active`** (re-checked at +0s and +12s; no cloud-function cleanup; `completedAt` never set). So neither "Back to Play" nor leaving to Home completes a finished session — the ONLY thing that does is the **destructive "End their game"** (which the next game offers as "Sam is playing a … game", misleading copy since nobody is actually playing). Net: a couple **cannot cleanly play two games in a row** — after every game, the next one is blocked until one partner kills the (already-finished) session. This breaks the core game loop for every session → **P1**. **Fix:** mark the session `completed` when both players reach results (or on "Back to Play"); don't let a completed/stale session block new games; fix the misleading "is playing" / "End their game" copy. | Play This or That to results on both → both tap "Back to Play" → Firestore `couples/{id}/sessions` still has that this_or_that doc `status=active` (12s later) → open any other game → "Waiting for Sam — Sam is playing a … game", only "End their game" unblocks. | Open |
| B-002 | Home → Play nav (play-as-user) | HomeScreen "Your partner is waiting to play" card → "Play now" | **P2** | The Home card explicitly promises resuming the specific waiting game — "**Your partner is waiting to play. A game is ready for the two of you. Jump back in and keep the ritual going.**" → **"Play now"** — but tapping it just lands on the **generic Play hub** (the game list). It does NOT open/resume the waiting game, and the Play hub shows **no indication of which game is waiting** nor any "resume" affordance. A user told to "jump back in" cannot tell what to tap or how to rejoin. (Also: BOTH partners' Home cards say "**your** partner is waiting to play" for the same session, so each thinks the other is mid-game.) **Fix:** "Play now" should deep-link into the active session (its play/results screen), or the Play hub should surface a "Resume — How Well" entry; the Home copy should reflect whose turn it actually is. | Cold start → Home → tap "Play now" → lands on Play hub, no waiting-game indicator. | Open |
| B-001 | Games / sessions | couples/{id}/sessions | **P1** (was P3→P2→P1) | **A finished game NEVER closes its session — there is no normal-user path to complete it — so every game leaves a dangling `status=active` session that blocks ALL other games.** Definitively proven on the R2-B2 restart: played This or That fully through on BOTH devices → both reached the results screen → **BOTH tapped the intended "Back to Play" button** → both navigated back to the Play hub, **but the session stayed `active`** (re-checked at +0s and +12s; no cloud-function cleanup; `completedAt` never set). So neither "Back to Play" nor leaving to Home completes a finished session — the ONLY thing that does is the **destructive "End their game"** (which the next game offers as "Sam is playing a … game", misleading copy since nobody is actually playing). Net: a couple **cannot cleanly play two games in a row** — after every game, the next one is blocked until one partner kills the (already-finished) session. This breaks the core game loop for every session → **P1**. **ROOT CAUSE (found in fix phase): a Firestore RULES bug, not app code.** The sessions `allow update` rule required `affectedKeys().hasOnly(['status','completedAt'])`, but the async-game completion path (`markUserComplete`) always writes **`completedByUsers`** (each player records themselves; the session flips to `completed` only once both are in). So every "I reached results" write was **denied** (the failure is swallowed by `onFailure`), `completedByUsers` never reached 2, and the session stayed `active` forever. `abandonSession` ("End their game") only diffs `status`/`completedAt`, so it passed the rule — exactly why that was the only thing that worked. | Play This or That to results on both → session stayed `active`; next game blocked. | **FIXED + DEPLOYED** — sessions `allow update` now permits `['status','completedAt','completedByUsers']`, lets any couple member record completion progress, keeps `startedByUserId` immutable + status monotonic (active→completed, never revert). **Re-verified LIVE:** played This or That fully on both → session auto-flipped to `status=completed`, `completedByUsers=[both]`, **0 active sessions** (no Back-to-Play/End-their-game needed); then **opened How Well immediately → its setup screen, NOT "Waiting for Sam"**. Core loop restored. |
| B-002 | Home → Play nav (play-as-user) | HomeScreen "Your partner is waiting to play" card → "Play now" | **P2** | The Home card explicitly promises resuming the specific waiting game — "**Your partner is waiting to play. A game is ready for the two of you. Jump back in and keep the ritual going.**" → **"Play now"** — but tapping it just lands on the **generic Play hub** (the game list). It does NOT open/resume the waiting game, and the Play hub shows **no indication of which game is waiting** nor any "resume" affordance. A user told to "jump back in" cannot tell what to tap or how to rejoin. (Also: BOTH partners' Home cards say "**your** partner is waiting to play" for the same session, so each thinks the other is mid-game.) **Fix:** "Play now" should deep-link into the active session (its play/results screen), or the Play hub should surface a "Resume — How Well" entry; the Home copy should reflect whose turn it actually is. | Cold start → Home → tap "Play now" → lands on Play hub, no waiting-game indicator. | **FIXED** — Home now resolves the active session's `gameType` → its resume route (`gameRouteFor`: wheel→SpinWheelRandom, this_or_that/how_well/desire_sync→themselves), stored as `HomeUiState.waitingGameRoute` and carried on `HomeAction.gameRoute`; `HomeActionTarget.Game` navigates there (fallback Play hub). Each game screen auto-joins the couple's active session on open, so "Play now" resumes the exact waiting game. **Verified LIVE:** Sam started This or That → QA Home "Play now" → landed directly in This or That (1/5), not the hub. |
| B-003 | Desire Sync results (copy/clarity) | DesireSyncScreen results | **P3** | The results stats are internally **inconsistent/confusing**. Header: "**3 shared desires — 2 answers stayed private.**" Per-person row: "**You 5 private / Sam 5 private**". Progress bar caption: "**3 shared, 2 kept private.**" So the same screen says both "2 kept private" (total) AND "5 private" (each person) — a user can't tell whether 3 are shared or all 5 stayed private. (Mechanically "5 private" likely means "all 5 of each person's raw answers stay private, 3 happened to overlap", but that framing isn't clear and contradicts the "2 kept private" line.) **Fix:** make the three counters consistent (e.g., drop or relabel the per-person "5 private", or clarify "your individual answers are always private"). | Play Desire Sync to results → read the three differing private/shared counts. | Open |
| B-003 | Desire Sync results (copy/clarity) | DesireSyncScreen results | **P3** | The results stats are internally **inconsistent/confusing**. Header: "**3 shared desires — 2 answers stayed private.**" Per-person row: "**You 5 private / Sam 5 private**". Progress bar caption: "**3 shared, 2 kept private.**" So the same screen says both "2 kept private" (total) AND "5 private" (each person) — a user can't tell whether 3 are shared or all 5 stayed private. (Mechanically "5 private" likely means "all 5 of each person's raw answers stay private, 3 happened to overlap", but that framing isn't clear and contradicts the "2 kept private" line.) **Fix:** make the three counters consistent (e.g., drop or relabel the per-person "5 private", or clarify "your individual answers are always private"). | Play Desire Sync to results → read the three differing private/shared counts. | **FIXED** — the per-person privacy tiles no longer show the contradicting "$total private"; they now read just "Private" (your individual answers always stay private), and the caption keeps the real "$matches shared, N kept private" breakdown. **Verified LIVE:** reveal now shows "You: Private / Sam: Private" + "5 shared, 0 kept private" — no contradiction. |
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
**R2-B2 Desire Sync playthrough (couple-shared premium):** QA (free) entered with NO paywall (A-001 holds live). Both played full 5 Yes/No; QA T,T,T,T,F + Sam T,T,F,T,F → results show **exactly 3 shared desires** (the mutual-yes Q1/Q2/Q4) with Q3 (mismatch) and Q5 (both no) correctly **hidden** — reveal/privacy logic CORRECT; results match on both devices; no crash. Findings: B-003 (P3 copy), C-DS-001 (P2 dark contrast on revealed list).
@ -61,12 +128,12 @@ game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informa
| ID | Area | Screen | Severity | Description | Status |
|---|---|---|---|---|---|
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
| C-CC-001 | Nav / layout — duplicate header + double back | ConnectionChallengesScreen (series list) | **P2** | The screen shows **TWO stacked "Connection Challenges" titles, each with its own back arrow** — a nav-scaffold app bar (title "Connection Challenges" + back) AND an in-content header ("Connection Challenges / Pick a series to build a habit together." + a second back arrow right below it). Verified it's a **redundant duplicate header, not a double-pushed route**: tapping the inner back arrow pops straight to the Play hub (same as the app-bar back). No dead-end, but two identical titles + two back buttons is confusing ("which back do I press?") and looks broken — exactly the "double back" case to flag. **Fix:** drop the in-content TopAppBar/back (let the nav scaffold own the title+back), or remove the scaffold bar for this route. | Play hub → Connection Challenges → two "Connection Challenges" headers + two back arrows stacked at top. | Open |
| C-DS-001 | Theming / readability (dark) | DesireSyncScreen results — "You both said yes to" list | **P2** | In **dark mode**, the revealed shared-desire list items render as **dim, low-contrast muted-pink text on a dark pink-tinted card** — legible but well below the crisp high-contrast black text the same items show in **light mode** (verified side-by-side: QA dark vs Sam light). Given the user's "text must be readable" bar — and that this is the intimate payoff content the user most wants to read — the dark-mode contrast is too low. (May be intentional "muted" styling; if so it needs a dark-mode-specific brighter token.) | 5554=Dark: play Desire Sync to results → the 3 shared-desire rows are dim/hard to read vs the same rows on 5556=Light. | Open |
| C-CC-001 | Nav / layout — duplicate header + double back | ConnectionChallengesScreen (series list) | **P2** | The screen shows **TWO stacked "Connection Challenges" titles, each with its own back arrow** — a nav-scaffold app bar (title "Connection Challenges" + back) AND an in-content header ("Connection Challenges / Pick a series to build a habit together." + a second back arrow right below it). Verified it's a **redundant duplicate header, not a double-pushed route**: tapping the inner back arrow pops straight to the Play hub (same as the app-bar back). No dead-end, but two identical titles + two back buttons is confusing ("which back do I press?") and looks broken — exactly the "double back" case to flag. **Fix:** drop the in-content TopAppBar/back (let the nav scaffold own the title+back), or remove the scaffold bar for this route. | Play hub → Connection Challenges → two "Connection Challenges" headers + two back arrows stacked at top. | **FIXED** — removed `CONNECTION_CHALLENGES` from `shellBackRoutes` (the screen renders its own header for both the pick + active views, unlike This or That/How Well/Desire Sync which rely on the shell). **Verified LIVE:** the screen now shows a single header + single back arrow (1 "Back", no duplicate title). |
| C-DS-001 | Theming / readability (dark) | DesireSyncScreen results — "You both said yes to" list | **P2** | In **dark mode**, the revealed shared-desire list items render as **dim, low-contrast muted-pink text on a dark pink-tinted card** — legible but well below the crisp high-contrast black text the same items show in **light mode** (verified side-by-side: QA dark vs Sam light). Given the user's "text must be readable" bar — and that this is the intimate payoff content the user most wants to read — the dark-mode contrast is too low. (May be intentional "muted" styling; if so it needs a dark-mode-specific brighter token.) | 5554=Dark: play Desire Sync to results → the 3 shared-desire rows are dim/hard to read vs the same rows on 5556=Light. | **FIXED**`DesireMatchCard` text color was a hardcoded dark plum `Color(0xFF3D1F2E)`; changed to theme-aware `MaterialTheme.colorScheme.onSurface` (dark text on light, light text on dark). **Verified LIVE:** played Desire Sync to reveal in dark mode → shared-desire rows now render crisp high-contrast white text. |
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
| C-NAV-001 | Nav / back-stack — onboarding+auth not popped after login | MainActivity AppNavigation (start/auth/onboarding graph) | **P1** | **The auth + onboarding destinations are never popped from the nav back stack after login, so pressing system Back from Home walks BACKWARD into onboarding → the welcome/login screen instead of exiting the app.** Confirmed with a CLEAN reproduction (no scripted pollution): cold start → land on **Home** (authenticated, "Connected with Sam") → press system **Back once** → lands on the **"Answer honestly" onboarding carousel** (still inside `closer.app/app.closer.MainActivity`, so it's in-app nav, not a separate task). Tapping the carousel's **Skip** then reaches **"Closer — Create account / I already have an account"** (the pre-auth welcome) — i.e., a logged-in user pressing Back appears to be logged out. Not data loss (cold start returns to Home; Firebase auth persists), but it's a core, every-user nav defect and very alarming UX. **Fix:** on successful auth/onboarding completion, navigate to Home with `popUpTo(<auth/onboarding graph or start route>) { inclusive = true }` (and `launchSingleTop`) so Home is the back-stack root and Back from Home exits the app. | Cold start (logged in) → Home → press system Back → onboarding carousel appears instead of the app closing. | Open |
| C-NAV-001 | Nav / back-stack — onboarding+auth not popped after login | MainActivity AppNavigation (start/auth/onboarding graph) | **P1** | **The auth + onboarding destinations are never popped from the nav back stack after login, so pressing system Back from Home walks BACKWARD into onboarding → the welcome/login screen instead of exiting the app.** Confirmed with a CLEAN reproduction (no scripted pollution): cold start → land on **Home** (authenticated, "Connected with Sam") → press system **Back once** → lands on the **"Answer honestly" onboarding carousel** (still inside `closer.app/app.closer.MainActivity`, so it's in-app nav, not a separate task). Tapping the carousel's **Skip** then reaches **"Closer — Create account / I already have an account"** (the pre-auth welcome) — i.e., a logged-in user pressing Back appears to be logged out. Not data loss (cold start returns to Home; Firebase auth persists), but it's a core, every-user nav defect and very alarming UX. **Fix:** on successful auth/onboarding completion, navigate to Home with `popUpTo(<auth/onboarding graph or start route>) { inclusive = true }` (and `launchSingleTop`) so Home is the back-stack root and Back from Home exits the app. | Cold start (logged in) → Home → press system Back → onboarding carousel appears instead of the app closing. | **FIXED** — in `AppNavigation.navigateRoute`, navigating to HOME *from* an entry route (ONBOARDING/CREATE_PROFILE/PAIR_PROMPT/LOGIN/SIGN_UP/FORGOT_PASSWORD) now does `navigate(HOME){ popUpTo(0){inclusive=true}; launchSingleTop }`, wiping the entire pre-app flow so HOME is the back-stack root. Normal tab-switch semantics (`selectTab`) untouched. **Re-verified LIVE:** cold start (logged in) → Home → system Back → focused activity is the **launcher** (`NexusLauncherActivity`), app exits cleanly — onboarding no longer resurfaces. |
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
@ -84,7 +151,7 @@ _Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| D-001 | Rules — missing subcollection rules | **P1** | `couples/{id}/capsules` and `couples/{id}/challenges` had **no `match` block** → default-deny → **Memory Lane hung on its loading heart** and **Connection Challenges** couldn't load (live `PERMISSION_DENIED` confirmed). Two premium features broken. | Sam premium, QA opens Memory Lane → stuck loading heart; logcat `Listen for Query(.../capsules) failed: PERMISSION_DENIED`. | **FIXED + DEPLOYED** — added member-read + ciphertext-enforcing `capsules` rule (title/content/promptUsed must be `enc:v1:`) and a `challenges` rule (catalog-referenced, progress-only). Re-verified live: Memory Lane shows empty state, Connection Challenges shows the series list, **0 permission errors**. |
| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | Open (Pass F) |
| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | **FIXED** (code) — ROOT CAUSE: `FirestoreCapsuleDataSource.observeCapsules` **swallowed** snapshot-listener errors (`if (err != null …) return@`), so on PERMISSION_DENIED the callbackFlow never emitted or closed → the ViewModel's `collect` suspended forever → stuck loading heart. Now it `close(err)`s the flow, so the ViewModel's existing `runCatching.onFailure` → `MemoryLanePhase.ERROR` (with a Retry) runs. Build green; live-verify needs an induced query failure (deferred). (Other snapshot listeners with the same swallow pattern are a follow-up sweep.) |
| (outcomes) | Rules | — | The Round-1 `outcomes` list `PERMISSION_DENIED` is **by-design** — the rule restricts reads to specific dayKeys (`day_0/30/60/90`); a bare list query is correctly denied. Not a bug. | — | Closed (by-design) |
## Pass E — Notifications
@ -95,6 +162,6 @@ _Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | Open |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | **FIXED** (code; live-tap deferred) — added `PARTNER_UNPAIRED` type, mapped `partner_left` + `partner_deleted_account` → it → routes to **HOME** (where the now-unpaired user gets the "Invite partner" CTA, matching the push body "Tap to create a new invite"). **Investigation corrected two false positives:** `invite_created` is a server-side **audit-log** entry (`read:true`, "not read by clients" — never a push), and `spki` is a **crypto key-format string** in the RevenueCat webhook (`crypto.createPublicKey({type:'spki'})`), not a notification type at all — neither needs client routing (documented in `fromRemoteType`). Build green; live tap-verify deferred (needs an actual unpair event). |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._

View File

@ -129,6 +129,7 @@ class MainActivity : AppCompatActivity() {
questionId = intent.getStringExtra("question_id"),
conversationId = intent.getStringExtra("conversation_id"),
gameSessionId = intent.getStringExtra("game_session_id"),
gameType = intent.getStringExtra("game_type"),
capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"),
avatarUrl = intent.getStringExtra("sender_avatar_url")

View File

@ -590,7 +590,10 @@ private val shellBackRoutes = setOf(
AppRoute.THIS_OR_THAT,
AppRoute.HOW_WELL,
AppRoute.DESIRE_SYNC,
AppRoute.CONNECTION_CHALLENGES,
// NB: CONNECTION_CHALLENGES is intentionally NOT here — that screen renders its OWN
// header (title + "Pick a series…" subtitle + back) for both the pick and active views.
// Adding it to the shell set drew a SECOND "Connection Challenges" app bar + back arrow
// on top of the screen's own (C-CC-001 duplicate header / double back).
AppRoute.WAITING_FOR_PARTNER,
AppRoute.SUBSCRIPTION,
AppRoute.WHEEL_HISTORY,

View File

@ -78,6 +78,7 @@ class AppMessagingService : FirebaseMessagingService() {
questionId = message.data["question_id"],
conversationId = message.data["conversation_id"],
gameSessionId = message.data["game_session_id"],
gameType = message.data["game_type"],
capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"],
avatarUrl = message.data["sender_avatar_url"]

View File

@ -67,7 +67,12 @@ class FirestoreCapsuleDataSource @Inject constructor(
val reg = col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
// Propagate listener failures (e.g. PERMISSION_DENIED) by closing the flow so
// the collector's error handling runs. Previously this swallowed the error
// (`return@`), so the flow never emitted or closed and the Memory Lane screen
// hung on its loading indicator forever (F-OBS).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { doc ->
runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
})

View File

@ -9,6 +9,7 @@ import androidx.core.app.NotificationManagerCompat
import app.closer.MainActivity
import app.closer.R
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
@ -250,6 +251,15 @@ enum class PartnerNotificationType(
body = "Tonight's question is a good reason to reconnect.",
channelId = NotificationChannelSetup.CHANNEL_REMINDERS,
rateType = NotificationRateLimiter.Type.REMINDER
),
// Partner left the couple or deleted their account — the user is now unpaired.
// Routed to Home, which surfaces the "Invite partner" CTA for the now-solo user
// (matches the push body "Tap to create a new invite"). E-002.
PARTNER_UNPAIRED(
title = "You're no longer paired.",
body = "Tap to create a new invite.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
);
/**
@ -258,9 +268,16 @@ enum class PartnerNotificationType(
fun routeFor(payload: PartnerNotificationPayload, coupleId: String = ""): String = when (this) {
PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION
REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY
PARTNER_STARTED_GAME -> AppRoute.PLAY
PARTNER_COMPLETED_PART -> AppRoute.PLAY
GAME_RESULTS_READY -> AppRoute.PLAY
// Deep link straight into the waiting game (each game screen auto-joins the couple's active
// session on open), not the generic Play hub — the push says "Tap to join". Falls back to the
// hub only if the server didn't send game_type. E-003.
PARTNER_STARTED_GAME -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY
PARTNER_COMPLETED_PART -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY
// Results-ready means the session is COMPLETED, so the plain game route would show "start a
// new game" (getActiveSession returns only active sessions). Deep link to the per-session
// results/replay route instead, using game_session_id + game_type from the FCM data. Falls
// back to the hub only if the server didn't send the session id. E-003 (results-ready).
GAME_RESULTS_READY -> gameResultsRouteFor(payload.gameType, payload.gameSessionId) ?: AppRoute.PLAY
CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
GENTLE_REMINDER -> AppRoute.DAILY_QUESTION
@ -275,6 +292,7 @@ enum class PartnerNotificationType(
PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME
DATE_MATCH -> AppRoute.DATE_MATCHES
REENGAGEMENT -> AppRoute.DAILY_QUESTION
PARTNER_UNPAIRED -> AppRoute.HOME
}
companion object {
@ -298,6 +316,10 @@ enum class PartnerNotificationType(
"partner_joined" -> PARTNER_JOINED
"date_match" -> DATE_MATCH
"reengagement" -> REENGAGEMENT
"partner_left", "partner_deleted_account" -> PARTNER_UNPAIRED
// NB: "invite_created" is a server-side audit-log entry (read:true, never sent
// as a push) and "spki" is a crypto key-format string in the RevenueCat webhook
// (not a notification type at all) — neither needs client routing. E-002.
else -> null
}
}
@ -312,8 +334,38 @@ data class PartnerNotificationPayload(
val questionId: String? = null,
val conversationId: String? = null,
val gameSessionId: String? = null,
/** The active session's game type (e.g. "this_or_that"), used to deep link into the game. E-003. */
val gameType: String? = null,
val capsuleId: String? = null,
val challengeId: String? = null,
/** Sender's avatar URL, used as the notification large icon when present. */
val avatarUrl: String? = null
)
/**
* Maps a session's [GameType] to the entry route that resumes/joins it. Each game screen detects the
* couple's active session on open and joins it, so deep linking here drops the partner straight into
* the waiting game (mirrors HomeViewModel.gameRouteFor for the Home "Play now" CTA). E-003.
*/
private fun gameRouteForType(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
/**
* The per-session results/replay route for a COMPLETED game, so a "results ready" push opens the
* actual results (not the game's setup screen). Needs both the game type and the session id. E-003.
*/
private fun gameResultsRouteFor(gameType: String?, sessionId: String?): String? {
if (sessionId.isNullOrBlank()) return null
return when (gameType) {
GameType.WHEEL -> AppRoute.wheelComplete(sessionId)
GameType.THIS_OR_THAT -> AppRoute.thisOrThatReplay(sessionId)
GameType.HOW_WELL -> AppRoute.howWellReplay(sessionId)
GameType.DESIRE_SYNC -> AppRoute.desireSyncReplay(sessionId)
else -> null
}
}

View File

@ -899,9 +899,15 @@ private fun DesireRevealMeter(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Each person's individual answers always stay private — only mutual
// "yes" answers surface as shared desires. Previously these tiles showed
// "$total private" (e.g. "5 private"), which contradicted the caption's
// "${total - matches} kept private" (e.g. "2 kept private") and confused
// whether 3 were shared or all 5 stayed private (B-003). Show just the
// privacy guarantee; the shared/private breakdown lives in the caption.
DesirePrivacyTile(
label = "You",
value = "$total private",
value = "Private",
modifier = Modifier.weight(1f)
)
StatusGlyph(
@ -913,7 +919,7 @@ private fun DesireRevealMeter(
)
DesirePrivacyTile(
label = partnerName,
value = "$total private",
value = "Private",
modifier = Modifier.weight(1f)
)
}
@ -993,7 +999,10 @@ private fun DesireMatchCard(match: DesireMatch) {
Text(
text = match.question.text,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF3D1F2E),
// Was a hardcoded dark plum (Color(0xFF3D1F2E)) — fine on the light card in
// light mode, but dim/low-contrast on the dark-tinted card in dark mode
// (C-DS-001). onSurface adapts: dark text on light, light text on dark.
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 3,
overflow = TextOverflow.Ellipsis

View File

@ -47,7 +47,13 @@ data class WaitingForPartnerUiState(
val isLoading: Boolean = true,
val gameType: String = GameType.WHEEL,
val partnerName: String = "Partner",
val navigateTo: String? = null
val navigateTo: String? = null,
/**
* Route into the partner's active game so this user can play their part, instead of being
* stuck on this "waiting" screen. Every current game is async (both play on their own device),
* so the partner who lands here can always join. Null only for an unknown game type. B-004.
*/
val joinRoute: String? = null
)
@HiltViewModel
@ -81,7 +87,8 @@ class WaitingForPartnerViewModel @Inject constructor(
it.copy(
isLoading = false,
gameType = session.gameType,
partnerName = partnerName
partnerName = partnerName,
joinRoute = gameTypeRoute(session.gameType)
)
}
}
@ -173,11 +180,30 @@ fun WaitingForPartnerScreen(
style = MaterialTheme.typography.bodySmall
)
Button(
onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to Games")
// Primary action: join the partner's game and play your part. Without this the
// screen was a dead-end for async games the user could actually play (B-004).
state.joinRoute?.let { route ->
Button(
onClick = { onNavigate(route) },
modifier = Modifier.fillMaxWidth()
) {
Text("Join the game")
}
}
if (state.joinRoute != null) {
TextButton(
onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to Games")
}
} else {
Button(
onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to Games")
}
}
TextButton(onClick = viewModel::abandonPartnerGame) {
Text(
@ -200,6 +226,15 @@ private fun gameTypeLabel(gameType: String): String = when (gameType) {
else -> gameType
}
/** Entry route that joins the partner's active game (each game screen auto-joins on open). B-004. */
private fun gameTypeRoute(gameType: String): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
private fun gameTypeGlyphKey(gameType: String): String = when (gameType) {
GameType.WHEEL -> "play"
GameType.THIS_OR_THAT -> "question"

View File

@ -216,7 +216,8 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
HomeActionTarget.AnswerReveal -> onReveal()
HomeActionTarget.Game -> onNavigate(AppRoute.PLAY)
// Resume the specific waiting game when known (B-002); fall back to the Play hub.
HomeActionTarget.Game -> onNavigate(action.gameRoute ?: AppRoute.PLAY)
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)

View File

@ -3,9 +3,11 @@ package app.closer.ui.home
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.crypto.SealedRevealManager
import app.closer.domain.model.GameType
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
@ -89,7 +91,10 @@ data class HomeAction(
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null
val categoryId: String? = null,
// For the "your partner is waiting to play" CTA: the specific game route to resume
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
val gameRoute: String? = null
)
data class PendingActionCard(
@ -99,6 +104,19 @@ data class PendingActionCard(
val target: HomeActionTarget
)
/**
* The entry route that resumes an in-progress game of [gameType]. Each game screen
* detects the couple's active session on open and joins it, so navigating here lets the
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
*/
private fun gameRouteFor(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
@ -128,6 +146,9 @@ data class HomeUiState(
val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
// The route of the active game waiting for this user, so the Home "Play now" CTA
// resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
@ -243,6 +264,7 @@ class HomeViewModel @Inject constructor(
// Retention signal fetches — run in parallel, failures silently default to false.
var hasWaitingGame = false
var waitingGameRoute: String? = null
var hasActiveChallenge = false
var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false
@ -252,8 +274,9 @@ class HomeViewModel @Inject constructor(
val gameJob = async {
runCatching {
val session = questionSessionRepository.getActiveSessionForCouple(coupleId)
session != null && uid !in session.completedByUsers
}.getOrDefault(false)
?.takeIf { uid !in it.completedByUsers }
session to gameRouteFor(session?.gameType)
}.getOrDefault(null to null)
}
val challengeJob = async {
runCatching {
@ -276,7 +299,9 @@ class HomeViewModel @Inject constructor(
.any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false)
}
hasWaitingGame = gameJob.await()
val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute
hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await()
@ -295,6 +320,7 @@ class HomeViewModel @Inject constructor(
partnerLeftEvent = false,
needsRecovery = needsRecovery,
hasWaitingGame = hasWaitingGame,
waitingGameRoute = waitingGameRoute,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule,
@ -598,7 +624,8 @@ class HomeViewModel @Inject constructor(
body = "A game is ready for the two of you. Jump back in and keep the ritual going.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual
tone = HomeActionTone.Ritual,
gameRoute = waitingGameRoute
)
Priority.CHALLENGE_WAITING -> HomeAction(

View File

@ -119,7 +119,10 @@ fun PaywallScreen(
)
uiState.error != null -> ErrorState(
title = "Couldn't load plans",
message = uiState.error ?: "Check your connection and tap to try again.",
// Always show friendly copy — the raw billing/RevenueCat SDK message (e.g.
// "There was a credentials issue. Check the underlying error…") is developer
// detail and must never surface to users. A-OBS.
message = "We couldn't load subscription options right now. Check your connection and tap to try again.",
retryLabel = "Try again",
onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth()

View File

@ -128,6 +128,7 @@ private fun PlayHubContent(
item {
DesireSyncCard(
showPremiumBadge = !hasPremium,
onClick = { onPlay(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
)
}
@ -140,6 +141,7 @@ private fun PlayHubContent(
item {
MemoryLaneCard(
showPremiumBadge = !hasPremium,
onClick = { onPlay(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
)
}
@ -283,6 +285,7 @@ private fun ThisOrThatCard(
@Composable
private fun DesireSyncCard(
showPremiumBadge: Boolean = true,
onClick: () -> Unit
) {
CloserClickableCard(
@ -321,27 +324,31 @@ private fun DesireSyncCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
// Hide the "🔒 Premium" badge once the couple has premium access — the
// feature is unlocked for both, so the lock badge is misleading (A-003).
if (showPremiumBadge) {
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@ -502,6 +509,7 @@ private fun ConnectionChallengesCard(
@Composable
private fun MemoryLaneCard(
showPremiumBadge: Boolean = true,
onClick: () -> Unit
) {
CloserClickableCard(
@ -546,27 +554,30 @@ private fun MemoryLaneCard(
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
// Hide the "🔒 Premium" badge once the couple has premium access (A-003).
if (showPremiumBadge) {
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
}
}
}
}

243
docs/brand/asset-system.md Normal file
View File

@ -0,0 +1,243 @@
# Closer Artwork Asset System
This is the working artwork asset set for Closer. It keeps the existing purple/pink color scheme
and the 2D pastel couple illustration style, but gives every visual surface a clearer job.
The brand should feel like a private ritual for two people: warm, quiet, equal, and intentional.
The heart remains the compact brand mark. The couple artwork should become the primary visual
language anywhere there is enough space to show a human moment.
## Current Artwork Review
### README and Positioning
The README is aligned with the right promise: private mutual reveal, real encryption, calm UX, no
public social mechanics, and one subscription per couple. That is the brand spine. Asset decisions
should reinforce that before adding more romance, gamification, or feature volume.
Recommended visual translation:
- Privacy: lock, sealed card, quiet room, closed journal, two-device reveal.
- Equality: paired shapes, mirrored halves, balanced figures, side-by-side cards.
- Ritual: daily card, cup/table setting, evening prompt, calendar/date cue.
- Warmth: existing pastel couple scenes, soft surfaces, pink/lavender accents.
Avoid:
- Generic dating-app hearts as the only signal.
- Loud streak/game badges as the main identity.
- Stock-couple photography.
- Notification copy or graphics that imply surveillance, urgency, or partner pressure.
### Existing Artwork
Keep:
- `docs/store/sources/app-icon.svg` as the source of the compact heart mark.
- `iphone/Closer/Resources/illustration-couple-*.png` as the human brand style.
- `iphone/Closer/Resources/pack-art-*.png` as the category/pack art direction.
- `iphone/Closer/Resources/particle-heart.png` and `particle-petal.png` for celebration moments.
Improve:
- The heart is strong for launchers and tiny UI, but too generic when used alone at larger sizes.
Use couple illustrations for onboarding, paywall, empty states, store screenshots, and web/social.
- Android currently has the launcher vectors but not the larger illustration library. Mirror the
iOS resources into Android when those screens start using raster art.
- Store feature graphics should show the heart plus one illustrated/private ritual scene, not only
cards and symbols.
### Notification Artwork
This section is only about the artwork used to represent notifications, not notification behavior.
- Use a dedicated monochrome notification glyph, not the colored launcher foreground.
- Keep notification artwork quiet: heart, lock, paired-card, moon, calendar, and capsule symbols.
- Large notification-adjacent artwork may use partner avatars or the couple illustration style, but
never show readable private answer content.
- The visual tone should say "gentle invitation," not alarm, urgency, surveillance, or streak loss.
## Master Asset Set
### 1. Brand Marks
| Asset | Use | Source / Target |
| --- | --- | --- |
| Primary app mark | Launcher, favicon, small brand moments | `docs/store/sources/app-icon.svg` |
| Adaptive foreground | Android launcher layer | `app/src/main/res/drawable/ic_launcher_foreground.xml` |
| Adaptive background | Android launcher layer | `app/src/main/res/drawable/ic_launcher_background.xml` |
| Monochrome mark | Android themed icon, single-color use | `app/src/main/res/drawable/ic_launcher_monochrome.xml` |
| Notification glyph | Android/iOS notification art direction | Needed: monochrome heart/paired-card source |
| Wordmark lockup | Website, store hero, press kit | Needed: `docs/brand/sources/closer-lockup.svg` |
| Horizontal logo | Email header, social headers | Needed: SVG + PNG exports |
| One-color logo | Legal docs, monochrome print, dark/light footer | Needed: SVG exports |
| Favicon set | Website/browser/PWA | Needed: 16, 32, 48, 180, 192, 512 px |
Logo rule: the heart mark should mean "two equal people meeting in the middle." Do not split it,
rotate it, add faces, add text inside it, or use it as a reaction emoji.
### 2. App Icons
| Platform | Required Assets |
| --- | --- |
| Android | Adaptive icon, round icon, themed monochrome icon, Play 512 px icon |
| iOS | AppIcon asset catalog: 20, 29, 40, 60, 76, 83.5, 1024 px |
| Web/PWA | favicon.ico, SVG favicon, Apple touch icon, maskable 192/512 icons |
Current status:
- Android and Play icon assets exist.
- iOS has an asset catalog folder but no visible app icon set in the checked file list. Add the
full iOS app icon set before TestFlight/App Store review.
### 3. Illustration Library
Use the 2D pastel couple art as the large-format identity.
| Asset Family | Current Assets | Primary Use |
| --- | --- | --- |
| Couple moments | `illustration-couple-onboarding`, `invite`, `history`, `paywall`, `subscription` | Onboarding, pairing, history, paywall |
| Product rituals | `illustration-daily-question`, `partner-activation`, `reveal-celebration` | Daily question, partner wait state, reveal |
| Play and progress | `illustration-spin-wheel`, `streak-milestone`, `together-empty` | Play hub, milestones, empty states |
| Pack art | `pack-art-*` | Question pack cards and category headers |
| Particles | `particle-heart`, `particle-petal` | Reveal, match, milestone celebration |
Needed additions:
- `illustration-privacy-lock`: two cards or journals behind a lock.
- `illustration-quiet-hours`: moon/window/two muted phones.
- `illustration-date-match`: two chosen date cards meeting.
- `illustration-memory-capsule`: sealed box/open capsule.
- `illustration-chat-thread`: two soft message bubbles with no readable text.
- `illustration-account-deletion`: calm export/delete privacy scene.
- Android copies of the iOS illustration assets, preferably under `app/src/main/res/drawable-nodpi/`.
### 4. Notification Artwork Assets
| Notification Type | Small Icon | Optional Large Icon | Route Mood |
| --- | --- | --- | --- |
| Partner answered | Heart/paired-card glyph | Partner avatar | Calm invitation |
| Reveal ready | Heart + unlock symbol | Heart mark | Moment is ready |
| Chat message | Heart/paired-card glyph | Partner avatar | Direct but private |
| Daily question | Heart + card | App mark | Daily ritual |
| Gentle reminder | Heart/soft nudge | Partner avatar optional | No pressure |
| Date match | Heart + calendar/card | Date-card art optional | Shared choice |
| Capsule unlocked | Heart + lock/open card | Capsule art optional | Memory opened |
| Challenge waiting | Heart + small path/card | Challenge art optional | Small next step |
Notification artwork rules:
- Never show readable answer text, prompt text, invite codes, message previews, or sensitive category
labels inside artwork.
- Avoid alarm-bell, siren, warning-triangle, fire, or countdown imagery.
- Prefer sealed cards, paired cards, soft hearts, moon/quiet-hours, calendar/date-card, and capsule
objects.
- Use the existing pink/lavender palette with high-contrast monochrome exports for platform glyphs.
### 5. In-App Icon and Glyph Set
Use Material/SF Symbols for normal UI controls. Use custom brand glyphs only for relationship
concepts that need a softer product voice.
Needed custom glyphs:
- `glyph-private-reveal`
- `glyph-two-people`
- `glyph-daily-card`
- `glyph-sealed-answer`
- `glyph-memory-capsule`
- `glyph-date-match`
- `glyph-quiet-hours`
- `glyph-couple-premium`
- `glyph-export-data`
- `glyph-delete-account`
These should be simple single-color vectors that work at 20-32 dp/pt.
### 6. Store and Marketing Assets
| Asset | Spec | Direction |
| --- | --- | --- |
| Play feature graphic | 1024 x 500 PNG | Heart + one private ritual illustration + short promise |
| Play screenshots | Up to 8 phone screenshots | Use product screens; omit login unless testing trust |
| App Store screenshots | iPhone 6.7", 6.5", 5.5" as needed | Mirror Play story |
| Social open graph | 1200 x 630 | Couple illustration + "A private space for two." |
| Press/brand header | 1600 x 900 | Illustration-led, logo lockup |
| Website hero | Responsive bitmap | Couple illustration in first viewport, not a symbol-only hero |
Recommended screenshot story:
1. Onboarding/private promise.
2. Home next-best action.
3. Daily question/private answer.
4. Mutual reveal/history.
5. Question packs.
6. Play/spin wheel.
7. Date planning.
8. Privacy/settings/subscription trust.
### 7. Empty States and Milestones
Each major empty state should have a matching illustration or glyph:
- No partner yet: `illustration-couple-invite`
- Waiting for partner: `illustration-partner-activation`
- No history yet: `illustration-couple-history`
- No messages yet: new `illustration-chat-thread`
- No date matches yet: new `illustration-date-match`
- No memory capsules: new `illustration-memory-capsule`
- Quiet hours enabled: new `illustration-quiet-hours`
- Premium/paywall: `illustration-couple-paywall` or `illustration-couple-subscription`
### 8. Motion and Celebration
Keep motion small and intimate:
- Heart pulse for pairing/reveal ready.
- Petal/heart particles for mutual reveal, date match, milestone.
- Slow card unlock animation for reveal/capsule.
- No confetti storms for sensitive answers.
### 9. File Organization
Recommended structure:
```text
docs/brand/
asset-system.md
visual-identity.md
sources/
closer-mark.svg
closer-lockup.svg
closer-lockup-horizontal.svg
notification-art/
notification-heart.svg
notification-paired-cards.svg
notification-quiet-hours.svg
notification-capsule.svg
glyphs/
exports/
logo/
store/
social/
app/src/main/res/
drawable/ # vectors exported from artwork sources
drawable-nodpi/ # raster illustrations shared with Android
mipmap-*/ # launcher icons
iphone/Closer/Resources/
Assets.xcassets/ # app icons and platform-managed assets
illustration-*.png
pack-art-*.png
```
## Priority Build List
1. Keep the current heart mark, but create wordmark/horizontal/one-color SVG lockups.
2. Add the missing iOS AppIcon set.
3. Mirror illustration PNGs into Android and start using them in onboarding, empty states, paywall,
and store screenshots.
4. Rework the Play feature graphic to include one couple/private-ritual illustration.
5. Add the new notification and privacy illustrations listed above.
6. Build a small custom glyph set for privacy/reveal/date/capsule concepts.

View File

@ -6,6 +6,9 @@ a public dating profile.
Product goal: **private, mutual-reveal relationship questions with real encryption and calmer UX**.
Visual decisions should reinforce that promise before decoration, novelty, or growth mechanics.
For the full working asset set, including illustration, notification, store, and logo-export
requirements, see `docs/brand/asset-system.md`.
## Brand mark
The mark is one heart formed by two equal halves. Pink and lavender represent two people meeting at

View File

@ -309,17 +309,20 @@ service cloud.firestore {
allow create: if isCouplesMember(coupleId)
&& request.resource.data.startedByUserId == request.auth.uid;
// Update: only the user who started the session can update it, OR valid status transitions.
// startedByUserId is immutable for direct client writes.
// Update: any couple member may record session progress/completion.
// (Async two-device games mark each player done via `completedByUsers`; the
// session flips active→completed once both are in. The previous rule only
// allowed `status`/`completedAt`, so every `completedByUsers` write was denied
// and finished games never closed — locking the couple out of new games. B-001.)
allow update: if isCouplesMember(coupleId)
// Either the original starter can update
&& (resource.data.startedByUserId == request.auth.uid
// Or status transition is valid: active → completed
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'))
// startedByUserId cannot be changed by clients
// startedByUserId is immutable for direct client writes.
&& request.resource.data.startedByUserId == resource.data.startedByUserId
// Only a fixed set of fields may change
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'completedAt']);
// Only session progress/completion fields may change.
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'completedAt', 'completedByUsers'])
// status is monotonic: stay the same, or transition active → completed (never revert).
&& (request.resource.data.status == resource.data.status
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'));
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;

View File

@ -196,6 +196,7 @@ async function notifyPartnerJoined(
title: 'Your partner joined!',
body: "You're connected. Time to answer tonight's question together.",
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: 'partner_joined',
couple_id: coupleId,

View File

@ -104,6 +104,7 @@ export const onCoupleLeave = functions.firestore
title: notificationPayload.title,
body: notificationPayload.body,
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: notificationPayload.type,
},

View File

@ -68,6 +68,7 @@ async function notifyDateMatch(
title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.",
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: 'date_match',
couple_id: coupleId,

View File

@ -65,7 +65,7 @@ export const onGameSessionUpdate = functions.firestore
await notifyPartner(
db, messaging, recipientId, starterName, gameType,
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
starterAvatar
starterAvatar, sessionId
)
return
}
@ -81,12 +81,12 @@ export const onGameSessionUpdate = functions.firestore
await notifyPartner(
db, messaging, partnerA, partnerBName, gt,
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
avatarB
avatarB, sessionId
)
await notifyPartner(
db, messaging, partnerB, partnerAName, gt,
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
avatarA
avatarA, sessionId
)
return
}
@ -104,7 +104,8 @@ async function notifyPartner(
notificationType: string,
body: string,
coupleId: string,
senderAvatarUrl?: string
senderAvatarUrl?: string,
sessionId?: string
): Promise<void> {
const title =
notificationType === 'partner_finished_game'
@ -161,10 +162,16 @@ async function notifyPartner(
title: notificationPayload.title,
body: notificationPayload.body,
},
// Put backgrounded notifications on the Games channel instead of the FCM fallback channel,
// so importance/sound and the per-category toggle apply. E-OBS.
android: { notification: { channelId: 'game_activity' } },
data: {
type: notificationPayload.type,
couple_id: coupleId,
game_type: gameType,
// Lets the client deep link a results-ready push to the per-session results/replay screen
// (a completed session isn't returned by getActiveSession). E-003 results-ready.
...(sessionId ? { game_session_id: sessionId } : {}),
...(senderAvatarUrl && senderAvatarUrl.length > 0
? { sender_avatar_url: senderAvatarUrl }
: {}),

View File

@ -191,6 +191,12 @@ async function sendNotification(
title: notification.title,
body: notification.body,
},
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
android: {
notification: {
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity',
},
},
data: {
type: notification.type,
...notification.data,

View File

@ -103,7 +103,11 @@ export const onAnswerWritten = functions.firestore
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({ ...payload, token } as admin.messaging.Message)
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
} as admin.messaging.Message)
)
)

View File

@ -94,7 +94,12 @@ export const onMessageWritten = functions.firestore
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({ ...payload, token } as admin.messaging.Message)
admin.messaging().send({
...payload,
token,
// E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
android: { notification: { channelId: 'partner_activity' } },
} as admin.messaging.Message)
)
)

View File

@ -118,6 +118,7 @@ async function notifyPartner(
title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.',
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: { type: 'partner_deleted_account' },
})
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,172 @@
# Closer Question Rewrite Plan
## Repo Alignment
This plan reflects the current repository schema.
Use these type names exactly:
- written
- single_choice
- multi_choice
- scale
- this_or_that
Do not rename them. Do not introduce `choice` or `either_or` unless the application code is updated first.
## Product Goal
Closer should feel like a couples game, not a survey or therapy worksheet.
Questions should make couples:
- smile
- laugh
- learn something new
- remember something
- plan something together
- have meaningful conversations
If a question feels like homework, rewrite it.
## Question Mix
Every 250 question pack must contain:
| Type | Count |
|---|---|
| multi_choice | 140 |
| single_choice | 50 |
| scale | 35 |
| this_or_that | 15 |
| written | 10 |
This means 190 of 250 questions, or 76%, are choice based.
## Emotional Mix
Aim for:
- 35% fun and playful
- 25% everyday relationship
- 20% meaningful conversation
- 10% future dreams and planning
- 10% deeper vulnerability
Never group heavy questions together.
Every category must contain lighter moments.
## Consumer First Rules
People should naturally answer dozens of questions in one sitting.
Every category should create moments like:
- "I didn't know that."
- "Really?"
- "That's adorable."
- "We should do that."
The product sells shared moments, not data collection.
## Type Definitions
### multi_choice
Select every option that applies.
Default to this type whenever possible.
### single_choice
Select one best answer.
### scale
Rate agreement, comfort, confidence, importance, or frequency.
### this_or_that
Very fast playful questions.
Should take under three seconds.
### written
Reserve for questions where typing creates significantly more value.
Never use written for simple preferences.
## Rewrite Rules
For every category:
- Rewrite every question from scratch.
- Follow the content guide.
- Keep category ids.
- Keep conversational wording.
- Avoid therapy language.
- Avoid repetitive templates.
- Use 4 to 6 options for choice questions.
- Keep written questions rare.
- Add fun throughout every category.
## Fun Injection
Every category should include at least:
- 10 playful questions
- 5 questions that create laughter
- 5 questions that inspire a future date, memory, or shared activity
Even serious categories should include enjoyable moments.
## Rewrite Order
1. fun
2. date_night
3. quality_time
4. communication
5. gratitude
6. future
7. home_life
8. values
9. stress
10. money
11. boundaries
12. conflict
13. conflict_repair
14. trust
15. rebuilding_trust
16. difficult_conversations
17. emotional_intimacy
18. physical_intimacy
19. couple_intimacy
20. sex_and_desire
21. sexual_preferences
## Validation
Every rewritten pack must pass:
- Valid JSON
- Exactly 250 questions
- 75 free
- 175 premium
- 140 multi_choice
- 50 single_choice
- 35 scale
- 15 this_or_that
- 10 written
- Valid depth values only: light, medium, deep
- No numeric depth values
- No malformed keys
- No duplicate ids
- No duplicate questions
- Every choice question has options
- Every scale question has scale configuration
- Every written question has answer configuration
- No therapy worksheet wording
- No placeholder text
- Every category feels fun, conversational, and rewarding

View File

@ -0,0 +1,442 @@
# Closer Question Schema
## Purpose
This document defines the JSON schema, question types, validation rules, and required counts for Closer question packs.
For writing philosophy and tone, see `QUESTION_CONTENT_GUIDE.md`.
For the active rewrite checklist and category order, see `QUESTION_REWRITE_PLAN.md`.
---
# 1. Question Types
## multi_choice
Select every option that applies.
Primary question type. At least 75% of all questions must be choice-based (multi_choice + single_choice).
```json
{
"type": "multi_choice",
"text": "What helps you feel relaxed on a date with me?",
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "good_food", "text": "Good food" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "physical_affection", "text": "Physical affection" },
{ "id": "clear_plan", "text": "Having a plan" }
]
}
```
Rules:
- Prompt should clearly allow multiple answers.
- Options should not shame either partner.
- Include practical and emotional options when possible.
## single_choice
Select one best answer.
```json
{
"type": "single_choice",
"text": "Which kind of date sounds best this week?",
"options": [
{ "id": "cozy_at_home", "text": "Cozy at home" },
{ "id": "dinner_out", "text": "Dinner out" },
{ "id": "something_playful", "text": "Something playful" },
{ "id": "something_outside", "text": "Something outside" },
{ "id": "surprise_me", "text": "Surprise me" }
]
}
```
Rules:
- Options should be short.
- Options should not overlap too much.
- Options should sound like real choices.
- Use 4 to 6 options.
## scale
Rate agreement, comfort, importance, confidence, or frequency.
```json
{
"type": "scale",
"text": "How much do you feel like we need a real date soon?",
"scale": {
"min": 1,
"max": 5,
"min_label": "Not much",
"max_label": "Very much"
}
}
```
Rules:
- Scale labels should be gentle and neutral.
- Do not make the low end sound bad or shameful.
- Scale questions should measure one thing only.
Bad:
```
How badly are we failing at making time for each other?
```
Better:
```
How much would some intentional time together help us right now?
```
## this_or_that
Very fast playful questions. Should take under three seconds.
```json
{
"type": "this_or_that",
"text": "Planned date or spontaneous date?"
}
```
Rules:
- Prompts should be quick.
- Avoid choices that imply one partner is wrong.
- Best used in fun, date, intimacy, home, and lifestyle packs.
## written
Reserved for questions where a short written response adds meaningful value.
Never use written questions for basic preferences.
```json
{
"type": "written",
"text": "What is one small thing I do that makes you feel cared for?",
"answer": {
"max_length": 500
}
}
```
Written questions require an `answer` configuration with `max_length`.
Good written examples:
```
What is one small thing I do that makes you feel cared for?
What is a memory of us that still makes you smile?
What is something you wish we made more time for?
```
Avoid written questions that are too vague:
```
How can we deepen our connection?
```
Better:
```
What is one thing we could do this week that would make us feel more like a team?
```
---
# 2. Required Counts
Every 250-question pack must contain:
| Type | Count | Percent |
|---|---|---|
| multi_choice | 140 | 56% |
| single_choice | 50 | 20% |
| scale | 35 | 14% |
| this_or_that | 15 | 6% |
| written | 10 | 4% |
At least 75% of all questions must be choice-based (multi_choice + single_choice).
Written questions should be rare and only used when typing creates a better conversation.
## Free vs Premium
| Access | Count |
|---|---|
| free | 75 |
| premium | 175 |
## Depth
Valid depth values:
- light
- medium
- deep
## Access
Valid access values:
- free
- premium
## Category Access
Valid category access values:
- free
- premium
- mixed
---
# 3. JSON Structure
## Category metadata
```json
{
"id": "date_night",
"title": "Date Night",
"access": "mixed",
"description": "Plan, enjoy, and remember time together.",
"count": 250,
"free_count": 75,
"premium_count": 175,
"questions": []
}
```
## Question object
```json
{
"id": "dn_001",
"type": "multi_choice",
"text": "What helps you feel relaxed on a date with me?",
"depth": "medium",
"access": "free",
"sex": "neutral",
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "good_food", "text": "Good food" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "physical_affection", "text": "Physical affection" },
{ "id": "clear_plan", "text": "Having a plan" }
]
}
```
## Scale question object
```json
{
"id": "dn_042",
"type": "scale",
"text": "How much do you feel like we need a real date soon?",
"depth": "medium",
"access": "free",
"sex": "neutral",
"scale": {
"min": 1,
"max": 5,
"min_label": "Not much",
"max_label": "Very much"
}
}
```
## This-or-that question object
```json
{
"id": "dn_089",
"type": "this_or_that",
"text": "Planned date or spontaneous date?",
"depth": "light",
"access": "free",
"sex": "neutral"
}
```
## Written question object
```json
{
"id": "dn_113",
"type": "written",
"text": "What is one date we should bring back?",
"depth": "medium",
"access": "premium",
"sex": "neutral",
"answer": {
"max_length": 500
}
}
```
Written questions require an `answer` configuration with `max_length`.
---
# 4. Validation Rules
Every rewritten pack must pass:
- Valid JSON
- Exactly 250 questions
- 75 free
- 175 premium
- 140 multi_choice
- 50 single_choice
- 35 scale
- 15 this_or_that
- 10 written
- Valid depth values only (light, medium, deep) — no numeric depth values
- Valid access values only (free, premium)
- Valid type values only (multi_choice, single_choice, scale, this_or_that, written)
- Valid sex values only (neutral, female, male)
- No duplicate question IDs
- No duplicate question texts
- No near-duplicate blocks
- No malformed keys
- No placeholder text
- No therapy worksheet tone
- Options have unique IDs inside each question
- Option IDs are neutral and clean
- Scale labels are present when needed
- Every choice question has options
- Every scale question has scale configuration
- Every written question has answer configuration
- Every category contains fun, curiosity, and meaningful conversations
## JSON Quality Checklist
- [ ] Valid JSON.
- [ ] Category id matches file purpose.
- [ ] Category title sounds user-facing.
- [ ] Category access matches actual free/premium strategy.
- [ ] Metadata counts match actual question counts.
- [ ] All question IDs are unique.
- [ ] All question texts are unique.
- [ ] No near-duplicate blocks.
- [ ] Type counts match metadata.
- [ ] Free/premium counts match metadata.
- [ ] Depth values are valid (light, medium, deep only).
- [ ] No numeric depth values.
- [ ] Access values are valid.
- [ ] Options have unique IDs inside each question.
- [ ] Option IDs are neutral and clean.
- [ ] Scale labels are present when needed.
- [ ] Every choice question has options.
- [ ] Every scale question has scale configuration.
- [ ] Every written question has answer configuration.
- [ ] No malformed keys like m a x _ l e n g t h.
- [ ] No placeholder text.
- [ ] No accidental old gendered IDs unless intentionally used.
---
# 5. Subtopic Planning
Each pack should have subtopics to prevent repetition.
Example for Date Night:
- easy dates
- low-cost dates
- at-home dates
- going out
- planning preferences
- romance style
- fun and play
- food and drinks
- music and movies
- seasonal dates
- surprises
- feeling wanted
- getting out of a rut
- after-kids / busy life
- date-night stress
- memories
- future date dreams
Example for Boundaries:
- alone time
- phone privacy
- social media
- family involvement
- friendships
- money
- work time
- rest
- chores
- home space
- conflict
- tone of voice
- physical affection
- private conversations
- parenting/family roles
- emotional bandwidth
- plans and scheduling
- personal belongings
- sleep
- stress
---
# 6. Repetition Check
A pack fails if it has blocks like this:
```
What boundary around alone time would help you feel respected?
What boundary around phone privacy would help you feel respected?
What boundary around family involvement would help you feel respected?
What boundary around friendships would help you feel respected?
```
Instead, each question should have its own angle.
Better:
```
When you need alone time, how should I respond so it does not feel personal?
What phone privacy boundary would help us feel trusted instead of watched?
How involved should family be in decisions that affect just the two of us?
What helps friendships feel healthy without making our relationship feel pushed aside?
```
---
# 7. Content Batching Process
Do not write all 250 questions in one creative pass.
Use this process:
1. Define pack purpose.
2. Define subtopics.
3. Write 40-50 high-quality questions.
4. Review tone and repetition.
5. Write the next batch.
6. Add choice, scale, and this-or-that questions.
7. Assign free/premium.
8. Assign depth.
9. Validate JSON.
10. Read the full file like a user.