Compare commits
26 Commits
bf3de8137b
...
aaab768cb0
| Author | SHA1 | Date |
|---|---|---|
|
|
aaab768cb0 | |
|
|
3de4178fd3 | |
|
|
ee19ef3f59 | |
|
|
d99fa6c6ea | |
|
|
f1549c642c | |
|
|
1b9d8cf8dc | |
|
|
a2b38485b1 | |
|
|
d1026c7312 | |
|
|
0c8586fa9e | |
|
|
682a9d8ea7 | |
|
|
fa6d80602a | |
|
|
afd81e8120 | |
|
|
c7140b1e10 | |
|
|
ddecfabcd9 | |
|
|
185d27b921 | |
|
|
6eeb86320b | |
|
|
8e08823e83 | |
|
|
46010508a9 | |
|
|
f63188d97a | |
|
|
f8ae15a41b | |
|
|
0c67c505be | |
|
|
a94f44d3ec | |
|
|
1fe4dea9c1 | |
|
|
edc00d2a5f | |
|
|
1108a57c4a | |
|
|
a82c43ad90 |
|
|
@ -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 P0–P2. 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 (D1–D6)
|
||||
_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.
|
||||
|
|
|
|||
|
|
@ -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, 1–5 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/
|
||||
|
|
|
|||
|
|
@ -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 P0–P2; 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 P0–P2.** 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 P0–P2 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._
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue