Compare commits

..

13 Commits

Author SHA1 Message Date
null 5d03fd98be chore(qa): keep fixture credentials in a gitignored local file, not the repo
Test-account emails/uids/passwords now live in qa/fixtures.local.md (gitignored);
qa/README.md carries only a pointer. Credentials must never be committed or pushed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:32:37 -05:00
null 3acba138a3 chore(functions): rebuild dist for deployed daily-question + game-copy changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:31:02 -05:00
null 15e4579410 docs(qa): R30 polish/UX/seed round — reports, coverage, review §5.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:06:36 -05:00
null ca67187754 fix(ui): strip internal 'mc' token from the reveal-screen category chip too
The daily reveal chip used displayCategoryName() (a different surface than the
Home pill fixed earlier), so it still showed 'Daily Fun Mc'. Fixed centrally in
the shared helper. Verified live: reveal now shows 'Daily Fun'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:04:54 -05:00
null f2321d3536 fix(functions): server-authoritative, mode-aware, deterministic daily question
Replace pickRandomQuestionId (empty pool → unresolvable q_default_daily fallback)
with pickDailyQuestionId(date): computes today's weekday mode (mirrors the client
DailyModeResolver.DOW_DEFAULTS, FROZEN) and deterministically indexes the free
daily pool by epochDay % poolSize — the SAME question the free client would pick,
now assigned server-side.

Fixes two things at the root:
- The daily 'questions' pool was empty, so every couple got q_default_daily, which
  the client can't resolve → each device fell back to its own local selection
  (the DQ-MISMATCH-001 class; the client half was pinned to the free pool in C1).
- Partners in different time zones computed different device-local weekdays and got
  different questions; a single server-assigned id makes both devices identical.

Firestore 'questions' seeded with the 75 free daily_fun_mc weekday questions (ids
match the client asset DB so getQuestionById resolves them). Pool fetched once and
filtered in memory — no composite index needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:57:22 -05:00
null fda53175a9 feat(games): nudge + per-game copy + score a11y batch C4 from UX review
- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
  partner is still playing, not once it's your turn) reusing the generic
  thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
  Toast on result incl. friendly rate-limit copy. Verified live: nudge →
  partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
  ('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
  for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
  Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
  ('You matched on N of M') and the How Well score ring Canvas ('You guessed N
  of M correctly') — both were split/Canvas visuals invisible to TalkBack.

Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:50:39 -05:00
null 8e8223ca97 + 2026-07-07 21:47:20 -05:00
null cf8f82357e feat(games): game-loop retention batch C3 from UX review
- NextBeatCard: a self-contained 'what's next' card under every game's results
  CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
  →' when today's daily is unanswered, else a 'Day N together' streak note. Own
  @HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
  four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
  guess you', starting a new round where they become the subject (Newlywed-style
  reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
  same first card') + skip already-swiped ideas so the deck resumes past them
  instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
  the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
  record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
  (1869 real prediction questions remain); DAO method has no other caller.

Unit suite green; assembleDebug clean; live-verified on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:39 -05:00
null 9d0a0c7fdf fix(onboarding,challenges): funnel + gating batch C2 from P3/UX review
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
  (platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
  palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
  per-uid, instead of an in-memory singleton that died on process death and made
  CreateProfile re-ask for the birth date after any restart mid-onboarding
  (verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
  so the auth-token/Firestore-rules race the old comment guarded against doesn't
  apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
  ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
  of spoiling the next prompt the moment today's step is marked done. +2
  ChallengeStateMachine tests.

Unit + functions suites green; verified live on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:24:27 -05:00
null 4ab79d34c4 fix(ui,games): defect-polish batch C1 from P3/UX review
- Challenges catalog: hide the 🔒 Premium badge once the couple has premium
  (A-003b; matches the Play hub's showPremiumBadge pattern). Verified live
  both directions under an authorized grant/revoke cycle.
- Game banner lifecycle (BANNER-LIFE-001): entering a session's screen now
  consumes any banner pointing at it (GamePromptController.consumeForSession
  wired into ActiveGameSessionMonitor.enter), and activity from a DIFFERENT
  session may replace a stale persistent banner. Verified live: no banner on
  reveal; stale RESULTS banner replaced by a new session's prompt.
- Waiting/join screen: says 'Your turn — {name} already played their part'
  for the non-starter once a first part landed (new partHasFinished mapped
  from partFinishNotifiedAt; completedByUsers only fills at reveal). Session
  observe mapping now also carries completedByUsers/joinedByUsers.
- How Well results: matched-row colors are now a theme-aware container+content
  pair (dark mode was near-invisible: fixed pale-green container under
  onSurfaceVariant text).
- Date Match: top card fully opaque — next card's text no longer bleeds
  through (was alpha 0.96).
- functions: don't send 'X finished — see your results!' for abandoned/quit
  sessions (status flips to completed with empty completedByUsers; a real
  completion always has both uids). Found live when a quit triggered a false
  banner. Needs deploy (bundled with C4).

Unit + functions suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:03:22 -05:00
null d554483ef2 docs(qa): R29 games/mechanics/retention/UI review round
- ClaudeGamesReview.md (new): per-game fidelity verdicts vs real-world analogs,
  live 2-device results, retention assessment (Hooked/Fogg/SDT lens) with
  prioritized recommendations, UI warmth scores, content-DB audit notes.
- ClaudeReport.md: R29 verdict entry (2 P1 + 2 P2 found & fixed, batches
  e5868bd + c36a101; new Ava/Ben fixture couple; premium lifecycle verified).
- ClaudeQACoverage.md: R29 coverage line (Pass B re-run pass, onboarding live
  pass, daily loop fail->fixed->re-verified, deferred items listed).
- Future.md: structural refactor specs (session-game engine dedupe, replay VM,
  shared composables, dispatcher injection, error surfacing) + retention and
  content follow-ups from the review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:54:05 -05:00
null c36a101d38 refactor(cleanup): dead code + hygiene batch from games review
- Delete unused ui/theme/Color.kt (zero refs; live palette is Theme.kt).
- Remove orphaned EditProfileScreen wrapper composable (no route references it;
  AccountScreen embeds EditProfileContent, which stays).
- Debug-gate ArtPreviewScreen + PairedHomePreviewScreen out of the release nav
  graph (BuildConfig.DEBUG).
- Collapse duplicate wheel_history route into game_history (same screen; wheel
  surfaces now navigate to Past Games directly).
- CouplePremiumChecker: inject AuthRepository instead of raw
  FirebaseAuth.getInstance() (finishes the in-flight auth-DI refactor for
  non-data-layer call sites).
- Copy/UI polish: 'It's a match!' (matches the push copy), internal 'mc' token
  never renders in the Home daily pill ('Daily Fun Mc' → 'Daily Fun'),
  welcome/splash privacy tagline no longer clips mid-sentence (maxLines 4 +
  ellipsis, AUTH-TRUNC-001).

Unit + functions suites green; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:48:56 -05:00
null e5868bd6b1 fix(games,daily): correctness batch from games review
- Game answer listeners (ToT/HowWell/DesireSync): close(err) instead of
  swallowing snapshot errors, + .catch in each VM's observeReveal surfacing a
  retryable ERROR (GameCopy.SYNC_ERROR + retrySync re-attach) — games no longer
  hang on WAITING forever on listener failure (GAME-HANG-001; matches the
  Wheel/Capsule sources' established pattern).
- Daily question: paired fallback pool is now premium-INDEPENDENT so both
  partners always resolve the same deterministic question; viewer-premium pools
  broke the couple contract when entitlement state differed or flipped mid-day
  (DQ-MISMATCH-001, reproduced live: partners answered different questions).
- Daily reveal: humanize raw option-id fallbacks so slugs never render
  (DQ-SLUG-001, e.g. 'fake_awards_should_be_mandatory').
- assignDailyQuestion.ts: replace hardcoded CST_OFFSET_HOURS=-6 with DST-safe
  Intl America/Chicago helpers (DST-001) + 5 regression tests (CDT/CST labeling,
  6PM reveal instant, spring-forward round-trip).

Verified live 2-device: identical daily question on both partners post-fix;
ToT full loop 5/5 reveal regression clean. Unit 244 + functions 58 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:41:41 -05:00
57 changed files with 1362 additions and 526 deletions

4
.gitignore vendored
View File

@ -91,3 +91,7 @@ docs/strategy/positioning-vs-paired.md
ClaudeQAPlan.md
ClaudeReport.md
relationship-app.code-workspace
# QA fixtures with test credentials — local only, never commit
qa/fixtures.local.md
qa/*.local.md

125
ClaudeGamesReview.md Normal file
View File

@ -0,0 +1,125 @@
# ClaudeGamesReview — Full Games, Mechanics, Retention & UI Review
> **Session 2026-07-07 (Claude, senior-dev review round).** Scope: all 7 games verified live on a
> fresh 2-emulator couple, mechanics checked against their real-world inspirations (researched),
> retention assessment, full-app UI warmth sweep, code-quality audit + fix batches.
> Bugs are cross-referenced by ID; canonical defect state lives in `ClaudeReport.md`.
> Test couple: **Ava + Ben** (`j39xR7PVZLLCveWnmenU`), created through the real signup → invite-code
> → recovery-phrase pairing flow on `CloserQA2` (5554, light) + `CloserCodexQA` (5556, dark).
> Premium was granted via admin (user-authorized), verified (partner unlock modal + push), then
> revoked (locks confirmed restored). 0 active sessions at session end.
## 1 · Per-game verdicts (mechanics fidelity + live results)
| Game | Real-world analog | Fidelity | Live 2-device result |
|---|---|---|---|
| This or That | Rapid-fire preference quiz | ✅ Faithful | **PASS** — engineered answers → exactly 3/5 "in sync"; simultaneous symmetric reveal; warm framing ("Lots in common 💛", neutral "Differ" chips) |
| How Well Do You Know Me | The Newlywed Game | ✅ Core faithful; no round-2 role swap (see R-2) | **PASS** — subject/guesser roles correct on both intros; engineered 2/5 exact; near-miss scale renders amber "4 → 3" (isClose softening); low score framed "Getting there!" |
| Desire Sync | Mojo Upgrade-style mutual-desire matching | ✅ Canonical privacy contract, verbatim in intro copy | **PASS** — engineered intersection → exactly "2 shared desires / 3 stayed private"; non-mutual yeses never rendered on either device; count doesn't leak whose/which; answers `enc:v1:` at rest |
| Spin the Wheel | Conversation-deck wheel | ✅ Honest randomness (category picked during spin; decorative stop angle) | **PASS** — identical 10-question set both sides; blind until both answered; side-by-side reveal; completion gate blocks finish with unanswered prompts |
| Connection Challenges | 7-day couples habit challenge | ✅ Day-gating real (can't complete 2 days in one day) | **PASS** — premium challenge opened with entitlement; day-1 complete → "waiting for partner"; ⚠ day-2 prompt text visible a day early (spoiler, see U-13) |
| Memory Lane | Time capsule / letter to future selves | ✅ | **PASS** — sealed capsule shows only title + "Opens in 29 days"; title AND body `enc:v1:` at rest (admin-verified) |
| Date Match | Tinder swipe-to-match | ✅ incl. one-sided-like privacy | **PASS** — simultaneous mutual-LOVE race → exactly **1** match doc; celebration modal + "It's a match!" push on BOTH devices |
**Shared engine seams, verified live:** one-active-game gate converges the second starter into the
active session (Ben's How Well attempt → "Ava is playing a Wheel game / Join the game");
first-finisher `partner_completed_part` fires as an in-app banner (foreground) and real push
(killed app, cold-start tap opened the app cleanly, 0 FATAL); mid-game Quit abandons correctly
(admin: 0 active sessions after); "you both finished" results banner on both sides.
## 2 · Bugs found (canonical detail in ClaudeReport.md)
| ID | Sev | Summary | Status |
|---|---|---|---|
| DQ-MISMATCH-001 | **P1** | Partners can resolve **different daily questions** for the same day: server assignment is the unresolvable `q_default_daily` rollout fallback → each device falls back locally, and the fallback **pool depends on the viewer's premium state at that moment** (per-user entitlement, transient failures→false, mid-day flips). Reveal then cross-compares answers to different questions. Reproduced live (Ava: premium pool "tiny win" q; Ben: free pool "tiny debate" q; same clock/tz). | **FIXED** (paired fallback pinned to free pool — premium-independent) + follow-up filed to seed real server assignments |
| DQ-SLUG-001 | **P1** | Daily reveal renders the partner's answer as a raw option-id slug ("fake_awards_should_be_mandatory") when the id isn't in this device's question config (same class as HW-BREAKDOWN-001). | **FIXED** (humanize fallback in both reveal summaries) |
| GAME-HANG-001 (B1) | **P2** | ToT/HowWell/DesireSync answer listeners swallowed snapshot errors → game stuck on WAITING forever, no feedback (Wheel/Capsule already had the correct `close(err)` pattern). | **FIXED** (sources `close(err)` + VM `.catch` → retryable ERROR with "Try again" re-attach) |
| DST-001 (B2) | **P2** | `assignDailyQuestion.ts` hardcoded `CST_OFFSET_HOURS=-6`: wrong date labels + reveal times shifted 1h for the ~8 CDT months. | **FIXED** (Intl/America-Chicago helpers, matches streakReminder.ts pattern; 5 DST regression tests incl. spring-forward round-trip) |
| A-003b | P3 | Challenges catalog still shows 🔒 Premium badge after couple premium unlock (gate itself honors premium — opens fine). | Filed |
| BANNER-LIFE-001 | P3 | "You both finished · View" game banner persists across screens/games long after results were viewed (still up entering a NEW game's setup, and shown on top of the results screen it links to). | Filed |
| AUTH-TRUNC-001 | P3 | Welcome/splash privacy tagline clips mid-sentence without ellipsis ("Only you and your paired") — first-run first impression. | Filed |
| Copy/theming P3s | P3 | "Daily Fun Mc" internal category name as user-facing pill; unthemed teal date picker; DOB asked twice in signup→profile funnel; "It is a match!" vs push "It's a match!"; HW matched-row question text low contrast in dark; Date Match card-underlay text bleed; joiner screen titled "Waiting for X" when it's your turn. | Filed |
## 3 · Retention assessment (Hooked / Fogg / SDT lens)
**Strong foundations:** daily-question blind-reveal is a genuine daily anchor with a variable
reward (partner's answer) · streaks with repair + grace (aligned with Duolingo's leniency findings)
· Memory Lane = stored-value investment · Weekly Recap + Your Progress = progress evidence ·
first-finisher nudges close the async loop (verified: banner + push both fire) · notification suite
respects quiet hours.
**Prioritized recommendations:**
- **R-1 (P1): Fix the daily anchor supply chain.** `q_default_daily` fallback means the server
never really assigns; seed the Firestore `questions` pool (or make the callable assign a real id
from a synced catalog) so both devices always agree. (Client-side consistency fix landed this
session; assignment seeding is the durable fix.)
- **R-2 (P2): Role-swap rematch in How Well.** Newlywed Game's round structure swaps roles; the
results screen should offer "Now you guess — swap roles" (starter becomes guesser). Cheap, doubles
sessions per sitting, and answers "what do we do next?" at the emotional peak.
- **R-3 (P2): Return-tomorrow beat on results screens.** After every reveal the only CTAs are
"Play again / Back to Play". Add the next anchor: tomorrow's question teaser, challenge day N+1,
or streak state ("Day 2 tomorrow — keep it alive").
- **R-4 (P2): Repeat-avoidance for game pools.** Desire Sync's effective pool is 102 binary items
drawn `shuffled().take(n)` per session (no cross-session memory) and Date Match's deck is
deterministic (identical first card for every couple, every install). Persist seen-ids per couple.
- **R-5 (P3): Purify the How Well pool.** `getQuestionsForPrediction()` pulls all choice/scale
questions including 500 `daily_fun_mc` novelty MCs ("laugh-track move") — dilutes the
partner-knowledge premise. Exclude `daily_fun_mc` (or curate a prediction-worthy tag).
- **R-6 (P3): Challenges — hide tomorrow's prompt** (spoiler kills the daily ritual) and consider a
visible 7-day chain with both partners' marks (social accountability).
- **R-7 (P3): Analytics funnel gaps.** `RetentionEvent` has GameStarted/GameCompleted but no
first-answer, reveal-viewed, waiting-abandoned, or notification-tap-attribution events — the async
dead-end (biggest churn risk) is unmeasurable today.
- **Content notes:** 48/150 sexual_preferences items unreachable by Desire Sync (non-binary configs
— fine, Wheel uses them) · `daily_fun_mc` rows carry `sex='neutral'` (currently benign only because
of the binary filter; retag to avoid a future landmine) · This or That mood pools healthy (6075
per mood vs 15 max draw); `rebuilding_trust` (45 q) reachable only via "All topics".
## 4 · UI warmth sweep (vs docs/copy-guide.md)
Scores 15 per cluster (tone / emotional safety / welcome / consistency):
- **Games (all 7): 5/5//4.** Best-in-class results framing (no shaming anywhere: "Getting
there!", "Differ" not ✗, "Nothing in common this round" never blames). Consistency dinged for the
banner lifecycle + slug leak (fixed) + per-game accent divergence (DS magenta is intentional and
works).
- **Onboarding/pairing: 4//5/3.** "You're in / Now bring your person in", value-prop checklist,
recovery-phrase ceremony with word-confirmation are excellent. Dinged: tagline truncation, DOB
asked twice, unthemed date picker, dead-end Create-Profile when signed out.
- **Home/Today: 4/5/5/3.** Daily card + mode system (Tiny Win Tuesday live-verified) is a strong
anchor; "Daily Fun Mc" pill leak; Home-card question can disagree with Today tab (DQ-MISMATCH).
- **Empty states: 5.** Memory Lane's "No capsules yet" is the pattern to copy everywhere.
- Both themes render cleanly on every screen driven this session (fresh-couple run covered light
on 5554 + dark on 5556 throughout); HW matched-row contrast is the one dark-mode ding.
## 5 · Code-quality fixes landed this session
- **Batch 1 (correctness):** B1 observer `close(err)` ×3 + VM `.catch`→retryable ERROR ×3 (+
`GameCopy.SYNC_ERROR`); DQ paired-fallback premium-independence; reveal slug humanization ×2;
DST-safe Chicago date helpers + 5 regression tests.
- **Batch 2 (dead code / hygiene):** see ClaudeReport.md fix log.
- **Filed (structural, specced in Future.md):** generic session-game engine (ToT/HW/DS VMs are
~80% clones; ~250-line state machine ×4), generic replay VM/screen (3× ~60-line clones), shared
waiting/length-picker composables, premium-check invocation helper, `GameConstants` for divergent
`ADVANCE_DELAY_MS` (420/380), dispatcher injection, error-surfacing standardization for 6
silent-load VMs, `npm audit` 10 moderate (GCP transitive).
## 5.1 · R30 polish + experience follow-up (2026-07-07)
All Tier-A P3s and Tier-B UX gaps from the review shipped and were live-verified (detail: ClaudeReport R30). Highlights that change the player experience:
- **Daily question is fixed at the root.** The server pool was empty (every couple got the unresolvable `q_default_daily`); now Firestore `questions` is seeded with the free weekday pool and the server picks the same mode-aware, deterministic question the client would — so both partners get the identical themed question, even across time zones. Verified: server assigns `daily_single_choice_weekly_v1_072` (Tiny Win Tuesday), both devices resolve it, no slugs.
- **Games no longer dead-end:** NextBeatCard ("Tonight's question is ready →" / streak) under every results screen; How Well offers a Newlywed-style role swap.
- **Content freshness:** Desire Sync remembers served questions; Date Match shuffles per couple and skips already-swiped ideas (no more identical first card for everyone).
- **Waiting isn't passive:** a "Send a little nudge 💜" button on the waiting screen (reuses the quiet-hours-safe thinking-of-you callable).
- **Onboarding polish:** themed M3 date pickers; the birth date now survives an app restart mid-signup (no more double-ask).
- **A11y + copy:** score visuals carry TalkBack descriptions; banners speak per-game ("guess their answers"); the internal "Daily Fun Mc" label never faces users.
## 6 · Coverage notes
- Verified live: all 7 games happy path 2-device · result extremes (0-match impossible for DS
reveal? engineered 3/5, 2/5, 2-shared, 3/5-sync variants) · one-active gate · abandon · cold-start
push join · simultaneous-LOVE race · premium grant/unlock/revoke lifecycle · daily answer→reveal ·
day-0 check-in · pairing + recovery-phrase ceremony · deep-link scheme audit (only /join/ links
exist; game entries are notification-driven, all tested).
- Deferred (unchanged from plan): full Pass E matrix, Pass K money path, release build, monkey,
TalkBack full pass (spot: n/a this round), TodayWidget interactive smoke (code-read only),
am-kill mid-session resume (cold-start-from-push covered the recovery class).

View File

@ -1,6 +1,10 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> **R30 (2026-07-07, polish + UX + seed):** all remaining P3s + 8 UX gaps FIXED & live-verified (see ClaudeReport R30 / ClaudeGamesReview §5). **Daily-question pool SEEDED + server made mode-aware/deterministic + deployed** — both devices resolve the same real server-pinned question (DQ-MISMATCH/DQ-SLUG closed at the root, verified live). Gates: unit pass · functions 58 · cold-start smoke run. Still deferred: TodayWidget interactive, TalkBack full pass, release/minify (O), device/OS matrix.
> **R29 (2026-07-07, games/mechanics/retention/UI review):** Pass B (all 7 games) **re-run live 2-device on a fresh couple = pass** (engineered-answer verification; see ClaudeGamesReview.md) · onboarding/signup/pairing/recovery-ceremony/day-0 check-in **pass (live, first full run since R24)** · daily answer→reveal loop **fail→DQ-MISMATCH-001/DQ-SLUG-001 → FIXED e5868bd, re-verified** · premium grant/unlock/revoke lifecycle **pass** · cold-start push open **pass (organic, killed app)** · one-active gate/abandon/simultaneous-match race **pass** · TodayWidget interactive + TalkBack spot + am-kill mid-session resume **todo (deferred; cold-start-from-push covered the recovery class)** · Pass D at-rest spot-check (game answers + capsule on new couple) **pass (`enc:v1:`)**.
> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.**
> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.**
> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b).

File diff suppressed because one or more lines are too long

View File

@ -83,6 +83,21 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
## QA
### From R29 games review (2026-07-07)
- **Seed the server daily-question pool**`assignDailyQuestion` still assigns the `q_default_daily` rollout fallback for every couple (Firestore `questions` empty), so clients always use the local fallback. Seed real ids (synced with the asset DB) so the assignment actually pins the couple's question. (Client-side premium-independence fix landed in R29; this is the durable half.)
- **Generic session-game engine** — ThisOrThat/HowWell/DesireSync embedded VMs are ~80% clones of one ~250-line state machine (load/create/join/submit/retry/observe/abandon) and their data sources are structural copies (`EncryptedAnswerMapDataSource<T>`). Migrate one game first (ToT), full live regression, then the rest.
- **Generic replay VM/screen** — 3× ~60-line clones (ToT/DS/HW replay).
- **Shared waiting/length-picker/error composables** — each game re-implements WaitingForPartner/length chips/error screens despite shared components existing.
- **Premium-check invocation helper** — CouplePremiumChecker is invoked 4 different ways across ~10 call sites with inconsistent failure fallback.
- **GameConstants** — divergent ADVANCE_DELAY_MS (420 vs 380), QUESTION_COUNT, SPIN_DURATION_MS per file; follow SessionLength's pattern.
- **DispatcherProvider injection** — Dispatchers.IO/Default hardcoded everywhere (testability).
- **Error-surfacing standardization** — BucketList/MessagesInbox/PlayHub/AnswerHistory/WeeklyRecap/Onboarding VMs swallow load errors (blank screens); adopt uiState.error + ErrorState for reads, _events snackbar for writes (ConversationViewModel pattern).
- **Retention (from ClaudeGamesReview.md):** HowWell role-swap rematch CTA · return-tomorrow beat on all results screens · per-couple seen-question memory for DS (102-item pool) + shuffle Date Match deck (deterministic first card for every couple) · exclude daily_fun_mc from HowWell prediction pool · hide Challenges day-N+1 prompt until its day · analytics: first-answer/reveal-viewed/waiting-abandoned/notif-tap events.
- **Content retag**`daily_fun_mc` rows carry `sex='neutral'` (benign today only via the binary filter; retag to keep Desire Sync's pool clean). 48/150 sexual_preferences items are non-binary configs (Wheel-only) — intended?
- **npm audit** — 10 moderate (retry-request/teeny-request via @google-cloud/storage transitive); revisit on next functions dependency bump.
- **Banner lifecycle** — GamePromptController results banner should dismiss once results are viewed (BANNER-LIFE-001).
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).
- **Tier 3: Compose screenshot diff for visual regression.** The static scanner in `scripts/theme-scan.sh`

465
README.md
View File

@ -1,269 +1,183 @@
<p align="center">
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="860" />
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="920" />
</p>
<h1 align="center">Closer</h1>
<p align="center">
<strong>A private space for two.</strong><br />
Private daily questions, intentional reveals, shared games, and calm rituals for couples.
Daily questions, mutual reveals, shared games, and calm rituals for couples.
</p>
<p align="center">
<img alt="Android" src="https://img.shields.io/badge/Android-Active%20development-3DDC84?style=for-the-badge&logo=android&logoColor=white" />
<img alt="iOS" src="https://img.shields.io/badge/iOS-Scaffold%20landed-007AFF?style=for-the-badge&logo=ios&logoColor=white" />
<img alt="Backend" src="https://img.shields.io/badge/Backend-Firebase-FFCA28?style=for-the-badge&logo=firebase&logoColor=black" />
<img alt="Min Android" src="https://img.shields.io/badge/Android-26%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white" />
<img alt="Min iOS" src="https://img.shields.io/badge/iOS-17%2B-007AFF?style=for-the-badge&logo=ios&logoColor=white" />
<img alt="Kotlin" src="https://img.shields.io/badge/Kotlin-2.x-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" />
<img alt="Swift" src="https://img.shields.io/badge/Swift-6.0-F05138?style=for-the-badge&logo=swift&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/license-Private-red?style=for-the-badge" />
<a href="#screenshots">Screenshots</a>
·
<a href="#what-makes-it-different">What makes it different</a>
·
<a href="#platform-status">Status</a>
·
<a href="#local-development">Run locally</a>
·
<a href="#qa-and-release">QA</a>
</p>
<p align="center">
<sub>
Android reference app · iOS scaffold in progress · Firebase backend · RevenueCat billing · Private repo
</sub>
</p>
---
> **Private daily questions for couples — end-to-end encrypted, never read, never sold.**
> *You and your paired partner hold the only key.*
Closer is a native relationship app for couples who want a quieter way to check in with each other.
Each partner answers privately, reveals intentionally, and keeps a record of the conversations that
matter.
A native couples-relationship app that turns check-ins into small, intentional rituals: one daily question, curated conversation packs, private answers, mutual reveal, gentle reminders, shared games, and date planning — with **real E2EE** and **calmer UX**.
It is not a social network, therapy replacement, or productivity tracker. There are no public feeds,
likes, followers, or infinite-scroll loops. The product loop is small on purpose:
Not a social network. Not therapy. Not a productivity tracker. **No public feeds, no likes, no followers, no infinite scroll.**
The core loop is simple: *answer honestly → choose what to reveal → keep a record of the conversations that mattered.*
## TL;DR
| What | Why it matters |
| --- | --- |
| 🔐 **Real E2EE** | Answers **and chat messages** are encrypted on-device. Server only sees ciphertext. Couple-owned keys via Tink (Android). |
| 💑 **One subscription per couple** | No double-billing partners. Premium unlocks for both — server-verified. |
| 🚫 **No engagement traps** | No infinite scroll. No likes. No follower counts. One daily question is the loop. |
| 🌗 **Decoupled theme + art** | In-app light/dark controls art; system theme isn't required to match. |
| 📱 **Native on both platforms** | Kotlin/Compose on Android, SwiftUI on iOS — same Firebase backend, same data model. |
| 🧪 **QA you can run** | `scripts/theme-scan.sh` (Pass C) and `scripts/wiring-scan.sh` (Pass N) catch the silent-dead-feature and theme-hardcoding classes before merge. |
---
```text
answer honestly -> reveal together -> keep the conversation going
```
## Screenshots
Fresh Android dark-mode captures from the current emulator build.
Daily question stages on Home:
<table>
<tr>
<td align="center"><strong>Ready</strong></td>
<td align="center"><strong>Your turn</strong></td>
<td align="center"><strong>Reveal ready</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/readme/home-daily-ready-dark.png" alt="Home screen with the daily question ready to answer in dark mode" width="220" /></td>
<td><img src="docs/screenshots/readme/home-daily-your-turn-dark.png" alt="Home screen after the partner answered and it is your turn in dark mode" width="220" /></td>
<td><img src="docs/screenshots/readme/home-daily-reveal-dark.png" alt="Home screen with the daily question reveal ready in dark mode" width="220" /></td>
</tr>
</table>
| Question Ready | Your Turn | Reveal Ready |
| :---: | :---: | :---: |
| <img src="docs/screenshots/readme/home-daily-ready-dark.png" alt="Home screen with the daily question ready to answer in dark mode" width="180" /> | <img src="docs/screenshots/readme/home-daily-your-turn-dark.png" alt="Home screen after the partner answered and it is your turn in dark mode" width="180" /> | <img src="docs/screenshots/readme/home-daily-reveal-dark.png" alt="Home screen with the daily question reveal ready in dark mode" width="180" /> |
<table>
<tr>
<td align="center"><strong>Play</strong></td>
<td align="center"><strong>This or That</strong></td>
<td align="center"><strong>Today</strong></td>
<td align="center"><strong>Challenge</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/readme/play-dark.png" alt="Play hub in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/this-or-that-dark.png" alt="This or That in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/today-dark.png" alt="Daily question in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/challenge-dark.png" alt="Connection challenge in dark mode" width="170" /></td>
</tr>
</table>
Other core screens:
## What makes it different
| Play | This or That | Today | Challenge |
| :---: | :---: | :---: | :---: |
| <img src="docs/screenshots/readme/play-dark.png" alt="Play hub in dark mode" width="160" /> | <img src="docs/screenshots/readme/this-or-that-dark.png" alt="This or That in dark mode" width="160" /> | <img src="docs/screenshots/readme/today-dark.png" alt="Daily question in dark mode" width="160" /> | <img src="docs/screenshots/readme/challenge-dark.png" alt="Connection challenge in dark mode" width="160" /> |
<table>
<tr>
<td width="33%"><strong>Private first</strong><br />Partners answer independently before seeing each other's response.</td>
<td width="33%"><strong>Mutual reveal</strong><br />The app is built around intentional sharing, not performative posting.</td>
<td width="33%"><strong>Couple-owned trust</strong><br />Android encrypts answers, chat, history, and media on-device with couple-owned keys.</td>
</tr>
<tr>
<td width="33%"><strong>One subscription per couple</strong><br />Premium unlocks for both partners through server-verified entitlements.</td>
<td width="33%"><strong>Curated prompts</strong><br />Question packs are written and reviewed, not generated at answer time.</td>
<td width="33%"><strong>Calm by design</strong><br />No feeds, likes, followers, public profiles, or pressure mechanics.</td>
</tr>
</table>
---
## Product surface
## Why Closer exists
Subscription apps for couples have a trust problem — confusing trial wording, hard-to-cancel flows, partners getting double-billed. Couples products have a *different* trust problem: partners are asked to be vulnerable in the same space where everything else (social, productivity, dating) wants their engagement, their data, and their attention.
Closer treats both the same way: **clear, straightforward, and built on honesty.**
- 🪞 **Private first, reveal second.** Each partner answers independently. Both decide what to share.
- 🧠 **Curated, not generated.** 6,000+ hand-written prompts across 22 categories — no AI confabulation in the core loop.
- 💸 **One sub, not two.** Subscription unlocks for both partners. Server-verified. No silent trial conversions.
- 🔒 **Encryption that earns the word.** Tink AEAD with couple-owned keys. Answers, messages, and history — server never sees plaintext. Recover with your phrase *or* your partner.
- 🌙 **Quiet hours, server-side.** Partner pushes respect the *recipient's* in-app window — not just foreground detection.
---
## What Closer does
| Feature | Free | Premium |
| Area | Free | Premium |
| --- | --- | --- |
| Daily question (text / scale / multi / this-or-that) | ✅ | ✅ |
| 6,000+ prompts · 22 question packs | ✅ (free) + 🎟️ (premium tiers) | ✅ (incl. premium-only packs) |
| Private answers + mutual-reveal flow | ✅ | ✅ |
| Spin the wheel — category-randomized questions | ✅ | ✅ |
| Recent answer history (last 30 days) | ✅ | ✅ |
| Full answer history (search, filter, export) | — | ✅ |
| Saved spin-wheel sessions | — | ✅ |
| Memory Lane (locked time capsules) | — | ✅ |
| Desire Sync (preferences alignment exercise) | — | ✅ |
| Select Connection Challenges (multi-day programs) | ✅ (free) + 🎟️ (premium tiers) | ✅ |
| Push reminders with quiet-hour support | ✅ | ✅ |
| Account deletion + data export | ✅ | ✅ |
| Daily question | Included | Included |
| Private answers + mutual reveal | Included | Included |
| Curated question packs | Free + mixed access | Full access |
| Spin the Wheel | Included | Included |
| This or That / How Well Do You Know Me | Included | Included |
| Recent answer history | Included | Included |
| Memory Lane capsules | Limited | Full access |
| Desire Sync | Limited | Full access |
| Connection Challenges | Free + mixed access | Full access |
| Date ideas, matches, and bucket list | Included | Premium ideas gated |
| Push reminders + quiet hours | Included | Included |
| Account deletion | Included | Included |
One subscription unlocks premium for **both** partners — `couples/{coupleId}/entitlements` is per-couple, not per-user.
---
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
flow exists.
## Platform status
| Platform | Stack | Status | Notes |
| --- | --- | --- | --- |
| **Android** | Kotlin · Jetpack Compose · Material 3 · Hilt · Room · DataStore | 🟢 **Reference implementation** | Feature-complete MVP, light/dark theme polished |
| **iOS** | SwiftUI · MVVM · async/await · Firebase iOS SDK | 🟡 **Scaffold landed on `dev`** | Full screen parity; pairing from iOS is blocked until E2EE keys are wired (Android-only today) |
| **Backend** | Firebase Auth · Firestore · Cloud Functions · FCM · App Check | 🟢 **Shared source of truth** | 17 callable/trigger/scheduled/webhook functions |
| **Billing** | RevenueCat · Google Play Billing · StoreKit | 🟢 **Server-verified** | Webhook → Firestore entitlements → `CouplePremiumChecker` |
| Platform | Status | Notes |
| --- | --- | --- |
| Android | Reference implementation | Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore, Firebase, Tink |
| iOS | Scaffold in progress | SwiftUI screen parity exists; pairing is blocked until E2EE interop is complete |
| Backend | Shared source of truth | Firebase Auth, Firestore, Cloud Functions, FCM, App Check |
| Billing | Server verified | RevenueCat webhook writes Firestore entitlements observed by the app |
> 📐 iOS scaffold has **all 49 screens** mapped to SwiftUI views, Firebase + RevenueCat integration, and full screen parity on the `dev` branch. CryptoKit-based E2EE parity (interop with Android's Tink key material) is the only blocker for end-to-end iOS pairing.
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
---
## Architecture at a glance
## Architecture
```text
┌──────────────────────────┐ ┌──────────────────────────┐
│ Android (Kotlin/Compose)│ │ iOS (SwiftUI) │
│ • Hilt DI · Room · DSK │ │ • MVVM · AppState · SPM │
│ • Tink AEAD + Argon2id │ │ • CryptoKit (follow-up) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
└──────────────┬─────────────────────┘
┌──────────▼──────────┐
│ Firebase │
│ • Auth (email / │
│ Google via │
│ Credential Mgr) │
│ • Firestore │
│ (couple-scoped) │
│ • Cloud Functions │
│ • FCM │
│ • App Check │
│ (Play Integrity)│
└──────────┬──────────┘
┌──────────▼──────────┐
│ RevenueCat │
│ → webhook │
│ → Firestore │
│ entitlements │
└─────────────────────┘
Android (Kotlin/Compose) iOS (SwiftUI)
Hilt · Room · DataStore MVVM · AppState · SPM
Tink AEAD · Argon2id CryptoKit interop in progress
\ /
\ /
v v
Firebase + RevenueCat
Auth · Firestore · Functions · FCM
App Check · server-verified billing
```
- **Couple-scoped data.** Firestore rules deny cross-couple reads/writes. Users only see their own + their partner's surface.
- **Server-mediated pairing.** 6-character invite codes are enumerable; invite reads/writes are server-side only.
- **Server-verified billing.** RevenueCat → Cloud Function webhook → Firestore `users/{uid}/entitlements/premium``CouplePremiumChecker` observes both partners' premium state.
- **Local-first questions.** Prompts ship in the app so daily questions load instantly; only assignment and sync hit the network.
Core rules:
---
- Couple-scoped Firestore data. Users only read/write their own couple surface.
- Server-mediated pairing. Invite lifecycle runs through Cloud Functions.
- Local-first question content. Prompts ship with the app; assignment/sync uses Firebase.
- Server-verified premium. Clients observe entitlements; they do not self-grant access.
- No anonymous auth. Accounts use email/password or Google sign-in.
## Tech stack
## Security and privacy
### Android
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
on-device before sync.
- The server stores ciphertext for private content and never receives plaintext answers.
- Recovery phrase wrapping uses Argon2id-derived keys.
- Partner-assisted restore lets a trusted partner help restore the couple key to a new device.
- Quiet hours are enforced server-side before push delivery.
- Account deletion exists; data export does not yet.
| Layer | Stack |
| --- | --- |
| Language | Kotlin 2.x |
| UI | Jetpack Compose · Material 3 · Navigation Compose |
| Architecture | Clean architecture — `core/` · `data/` · `domain/` · `ui/` |
| State | ViewModel · Kotlin Coroutines · Kotlin Flow |
| DI | Hilt |
| Local data | Room · DataStore Preferences · bundled SQLite seed |
| Crypto | Google Tink AEAD + Argon2id (Bouncy Castle KDF) |
| Auth | Firebase Auth · **Credential Manager** for Google Sign-In |
| Build | min 26 · target 35 · compile 35 · Java 17 · KSP |
The canonical technical reference is
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
### iOS
## Local development
| Layer | Stack |
| --- | --- |
| Language | Swift 6.0 |
| UI | SwiftUI · NavigationStack · TabView |
| Architecture | MVVM · `AppState` ObservableObject · `EnvironmentObject` |
| Concurrency | async/await · Swift 6 strict concurrency |
| Dependency management | Swift Package Manager · XcodeGen (`project.yml`) |
| Auth | Firebase Auth · Google Sign-In SDK |
| Crypto | Apple CryptoKit (E2EE parity — follow-up) |
| SDK | iOS 17.0+ |
<details open>
<summary><strong>Prerequisites</strong></summary>
### Backend (shared)
- Android Studio, Android SDK, and JDK 17
- Node 20 for Firebase Functions tooling
- Firebase project with Auth, Firestore, Cloud Messaging, Crashlytics, Analytics, and App Check
- `app/google-services.json`
- RevenueCat project and Android API key
| Layer | Stack |
| --- | --- |
| Auth | Firebase Authentication — **email/password · Google** (Android uses Credential Manager) |
| Database | Cloud Firestore (couple-scoped rules) |
| Server logic | Firebase Cloud Functions (TypeScript) |
| Push | Firebase Cloud Messaging (FCM) |
| Security | Firebase App Check · Play Integrity (Android) · DeviceCheck (iOS, planned) |
| Billing | RevenueCat (server-verified entitlements) |
| Analytics | Firebase Analytics · Crashlytics |
For iOS work:
> 🚫 **No anonymous auth.** There is no anonymous sign-in or account-linking flow in either platform. Accounts are email/password or Google.
- macOS, Xcode 16, XcodeGen, and iOS 17+
- `iphone/Closer/GoogleService-Info.plist`
- RevenueCat iOS API key
---
</details>
## Repository layout
```text
.
├── app/ # Native Android app (Kotlin)
│ └── src/main/java/app/closer
│ ├── core/ # Firebase, analytics, billing, nav, notifications, security
│ ├── data/ # Room, Firestore data sources, repositories, seed parsing
│ ├── domain/ # Models + repository contracts
│ └── ui/ # Compose screens + feature ViewModels
├── iphone/ # Native iOS app (SwiftUI)
│ ├── ARCHITECTURE_AUDIT.md # iOS port blueprint (49 screens, schema, models)
│ ├── project.yml # XcodeGen project spec
│ ├── Package.swift # SPM dependency manifest
│ └── Closer/
│ ├── Models/ # Firestore + domain codable types
│ ├── Core/ # Auth · Billing · Notifications
│ ├── Services/ # FirestoreService (callable wrappers)
│ ├── Theme/ # CloserTheme (colors, typography, spacing)
│ ├── Components/ # Shared SwiftUI components
│ ├── Navigation/ # Root ContentView + TabView routing
│ ├── Onboarding/ # Onboarding · login · signup · profile creation
│ ├── Pairing/ # Invite code · partner confirm · recovery
│ ├── Home/ # Home dashboard · streak · partner mirror
│ ├── Questions/ # Daily Q · answer reveal · history · packs
│ ├── Play/ # Play hub + games (ToT, HowWell, DesireSync, …)
│ ├── Wheel/ # Spin wheel
│ ├── Dates/ # Date swipe · matches · builder · bucket list
│ └── Settings/ # Settings · paywall · help · data export
├── functions/ # Firebase Cloud Functions (TypeScript)
│ └── src/
│ ├── auth/ # Auth + invite lifecycle
│ ├── billing/ # RevenueCat webhook + entitlement sync
│ ├── couples/ # Pairing, leave, daily-question triggers
│ ├── questions/ # onAnswerWritten · onMessageWritten · threads
│ ├── games/ # onGameSessionUpdate · onGamePartFinished
│ ├── notifications/ # quiet-hours helper · reminders
│ └── server/ # Internal Express webhook service
├── scripts/ # Automated QA / lint scanners
│ ├── theme-scan.sh # Pass C: light/dark theme-hardcoding scanner
│ └── wiring-scan.sh # Pass N: dead-feature / orphan-wiring scanner
├── server/ # Optional Express webhook/health service
├── seed/ # Question-pack JSON + local DB generation
├── docs/ # QA notes · release prep · roadmap · screenshots
└── firestore.rules # Firestore security rules (single source of truth)
```
> 🧪 `scripts/theme-scan.sh` and `scripts/wiring-scan.sh` are run before every QA pass. They statically catch the two costliest QA classes: hardcoded theme colors and silent dead features.
---
## Getting started
### Prerequisites
- **Android:** Android Studio · Android SDK · JDK 17
- **iOS:** Xcode 16 · macOS · [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`)
- **Firebase:** Project with Auth · Firestore · Cloud Messaging · Crashlytics · Analytics · App Check
- **Android config:** `app/google-services.json`
- **iOS config:** `iphone/Closer/GoogleService-Info.plist`
- **Billing:** RevenueCat project with Android + iOS API keys
- **Node 20** for Firebase Functions tooling
### Local config
<details>
<summary><strong>Android</strong></summary>
```bash
# Android
cp local.properties.example local.properties
# iOS
cp iphone/Closer/GoogleService-Info.plist.example iphone/Closer/GoogleService-Info.plist
```
```properties
@ -272,19 +186,16 @@ RC_API_KEY_ANDROID=your_revenuecat_android_key
RC_API_KEY_IOS=your_revenuecat_ios_key
```
### Android
```bash
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```
```bash
./gradlew :app:compileDebugKotlin # fast verification
./gradlew :app:testDebugUnitTest # 205 unit tests
```
</details>
### iOS
<details>
<summary><strong>iOS</strong></summary>
```bash
cd iphone
@ -294,82 +205,86 @@ xed Closer.xcodeproj
```bash
xcodebuild -project iphone/Closer.xcodeproj \
-scheme Closer \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
-scheme Closer \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
```
### Firebase Functions
</details>
<details>
<summary><strong>Firebase Functions</strong></summary>
```bash
cd functions
npm install
npm run build
npm test
```
```bash
npm run serve
```
```bash
npm test # 24 functions tests
</details>
## QA and release
The repo keeps repeatable QA checks close to the code:
| Check | Purpose |
| --- | --- |
| [qa/README.md](qa/README.md) | Ava + Ben emulator fixtures and cold-start smoke notes |
| `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions |
| `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks |
| [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist |
| [docs/release/](docs/release/) | Internal testing, store assets, and release prep |
Current Ava + Ben emulator sessions are documented in [qa/README.md](qa/README.md). Do not clear those
app sessions unless you are intentionally rebuilding the fixture.
## Repository map
```text
app/ Android app
iphone/ iOS app
functions/ Firebase Cloud Functions
server/ Optional Express service
seed/ Question content and seed tooling
scripts/ Static QA scanners
qa/ Re-runnable emulator QA notes and scripts
docs/ Architecture, release, screenshots, and QA docs
firestore.rules Firestore rules source of truth
```
### Optional server
```bash
cd server
npm install
npm run dev
```
---
## Security & privacy
- 🔐 **E2EE content.** Tink AEAD with couple-owned keys. Answers, **chat messages** (text *and* images), locked capsules, and conversation history are encrypted on-device — the server only ever sees ciphertext. Messages live under `couples/{coupleId}/conversations/…`; images upload to Storage as opaque encrypted bytes, and even the inbox preview line is encrypted.
- 💾 **Encrypted conversation backup.** Chat and thread history is backed up as couple-key ciphertext — cheap incremental appends plus periodic full snapshots — so history can return to a new device. The backup is never readable server-side.
- 🧂 **Key wrapping.** Argon2id KDF over the recovery phrase; keys wrapped client-side.
- 🪪 **Recovery phrase.** Server-blind; wiped from the inviter on acceptance. One of **two** recovery paths.
- 🤝 **Partner-assisted restore.** Lost or wiped your device? Your partner can restore your full history with **no recovery phrase**: your new device publishes a fresh public key, you read a 6-digit code aloud on a separate channel, and they wrap the couple key to that key (ECIES `keybox:v1:`). The server only relays the sealed blob — never the key itself. Your own devices get a *"was this you?"* security alert whenever a restore is requested or completes.
- 🚧 **Firestore rules.** Couple-scoped; deny-by-default; field allowlists on `users/{uid}` updates; shape-restricted couple create.
- 🛡️ **App Check.** Play Integrity (Android), DeviceCheck (iOS, planned) — blocks abusive backend access.
- 🌙 **Quiet hours, server-side.** Suppression is enforced where the push is **sent**, not where it might be foregrounded. Client cannot bypass by being backgrounded.
- 💸 **Server-verified billing.** Cloud Function writes `users/{uid}/entitlements/premium` from the RevenueCat webhook. Client cannot self-grant.
> Full architecture reference: [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — the canonical source of truth for the security model, data model, and Cloud Functions wiring.
---
## Roadmap
In progress:
- 🔐 **iOS E2EE parity** (CryptoKit interop with Android's Tink key material) — *unblocks pairing from iOS*.
- 🧪 **On-device / instrumented test coverage** (Compose UI / Espresso smoke) — currently `app/src/test` only.
- 🎨 **Activity `uiMode` sync** to in-app theme (C-DARKART-002) — the dark-variant `-night` PNGs only render in the right combination today; architectural fix in `MainActivity`.
- 🛒 **Real release config** — version, legal/support URLs, RevenueCat offerings verified end-to-end on internal testing.
- iOS E2EE interoperability with Android's Tink key material
- More on-device/instrumented smoke coverage
- Activity `uiMode` sync for in-app theme and dark artwork
- Internal-testing release configuration, legal/support URLs, and RevenueCat offering validation
Out of scope (for now):
Out of scope for now:
- AI-assisted question suggestions
- Native group/relationship types beyond dyadic couples
- Wearable (Wear OS / watchOS) companions
- AI-generated core questions
- Group relationship spaces beyond dyadic couples
- Wear OS / watchOS companions
- Live video or voice sessions
See `Future.md` for the full backlog.
---
## Project history & docs
## Project docs
| Doc | Purpose |
| --- | --- |
| [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, known landmines |
| [`docs/release/`](docs/release/) | Release prep + store assets |
| [`docs/qa/`](docs/qa/) | QA playbook + private-MVP checklist |
| `Future.md` | Backlog + roadmap |
| `HISTORY.md` | Changelog + release notes |
| `PROJECT.md` | Scope, feature matrix, architectural decisions |
---
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes |
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
| [docs/release/](docs/release/) | Release prep and store assets |
| [docs/qa/](docs/qa/) | Manual QA checklist |
| [Future.md](Future.md) | Backlog and roadmap |
| [HISTORY.md](HISTORY.md) | Changelog and release notes |
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
## License

View File

@ -2,7 +2,7 @@ package app.closer.core.billing
import app.closer.data.remote.FirestoreCollections
import app.closer.domain.repository.CoupleRepository
import com.google.firebase.auth.FirebaseAuth
import app.closer.domain.repository.AuthRepository
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@ -29,11 +29,12 @@ import javax.inject.Singleton
class CouplePremiumChecker @Inject constructor(
private val entitlementChecker: EntitlementChecker,
private val firestore: FirebaseFirestore,
private val coupleRepository: CoupleRepository
private val coupleRepository: CoupleRepository,
private val authRepository: AuthRepository
) {
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
fun isPremium(): Flow<Boolean> = flow {
val uid = FirebaseAuth.getInstance().currentUser?.uid
val uid = authRepository.currentUserId
val partnerId = uid?.let {
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
}

View File

@ -477,9 +477,6 @@ fun AppNavigation(
onNavigate = navigateRoute
)
}
composable(route = AppRoute.WHEEL_HISTORY) {
GameHistoryScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.GAME_HISTORY) {
GameHistoryScreen(onNavigate = navigateRoute)
}
@ -604,11 +601,14 @@ fun AppNavigation(
composable(route = AppRoute.ACTIVITY) {
ActivityScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
// Dev-only art/layout previews: keep them out of the release nav graph.
if (app.closer.BuildConfig.DEBUG) {
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
}
}
}
}

View File

@ -43,7 +43,6 @@ object AppRoute {
const val DELETE_ACCOUNT = "delete_account"
const val SECURITY = "security_settings"
const val CHANGE_PASSWORD = "change_password"
const val WHEEL_HISTORY = "wheel_history"
const val GAME_HISTORY = "game_history"
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
const val DESIRE_SYNC_REPLAY = "desire_sync_replay/{sessionId}"
@ -122,7 +121,6 @@ object AppRoute {
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(SECURITY, "Security", "settings"),
Definition(CHANGE_PASSWORD, "Change Password", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
Definition(GAME_HISTORY, "Past Games", "play"),
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
Definition(DESIRE_SYNC_REPLAY, "Desire Sync Results", "play"),
@ -178,7 +176,6 @@ object AppRoute {
SPIN_WHEEL_RANDOM,
WHEEL_SESSION,
WHEEL_COMPLETE,
WHEEL_HISTORY,
GAME_HISTORY,
THIS_OR_THAT_REPLAY,
DESIRE_SYNC_REPLAY,

View File

@ -58,7 +58,9 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active' AND TRIM(text) <> ''")
// Excludes daily_fun_mc: those are throwaway novelty MCs ("pick a laugh-track move") that
// dilute How Well's partner-knowledge premise. Leaves 1869 real prediction questions.
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND category_id <> 'daily_fun_mc' AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")

View File

@ -0,0 +1,37 @@
package app.closer.data.local
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Remembers which Desire Sync questions a couple has already been served, so consecutive rounds
* don't repeat prompts out of the ~100-item binary pool. Local-only and creator-side: the joiner
* plays the creator's exact set, so only the couple's shared history matters and there's nothing
* to sync. When the unseen remainder can't fill a round, the record is cleared to start a fresh
* cycle through the pool.
*/
@Singleton
class SeenDesireQuestionStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private fun keyFor(coupleId: String) = stringSetPreferencesKey("seen_desire_$coupleId")
suspend fun seen(coupleId: String): Set<String> =
dataStore.data.map { it[keyFor(coupleId)] ?: emptySet() }.first()
suspend fun markSeen(coupleId: String, ids: Collection<String>) {
dataStore.edit { prefs ->
prefs[keyFor(coupleId)] = (prefs[keyFor(coupleId)] ?: emptySet()) + ids
}
}
suspend fun clear(coupleId: String) {
dataStore.edit { it.remove(keyFor(coupleId)) }
}
}

View File

@ -62,7 +62,11 @@ class FirestoreDesireSyncDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}

View File

@ -72,7 +72,11 @@ class FirestoreHowWellDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}

View File

@ -69,7 +69,11 @@ class FirestoreThisOrThatDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
@Suppress("UNCHECKED_CAST")
val raw = snap.get("answers") as? Map<String, *>
val byUser = parseAnswers(raw, coupleId)

View File

@ -228,7 +228,14 @@ class QuestionSessionRepositoryImpl @Inject constructor(
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
isPremium = doc.getBoolean("isPremium") ?: false,
status = doc.getString("status") ?: "active",
gameType = doc.getString("gameType") ?: GameType.WHEEL
gameType = doc.getString("gameType") ?: GameType.WHEEL,
// Needed by the waiting/join screen to tell "they're playing"
// apart from "they finished — your turn".
completedByUsers = (doc.get("completedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
joinedByUsers = (doc.get("joinedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
partHasFinished = doc.getTimestamp("partFinishNotifiedAt") != null
)
}
.onFailure { crashReporter.recordException(it) }

View File

@ -133,7 +133,8 @@ object ChallengeStateMachine {
cta = cta,
badge = null,
canAdvance = canAdvance,
missedDate = missedDate
missedDate = missedDate,
statusDay = statusDay
)
}

View File

@ -1,17 +1,40 @@
package app.closer.domain
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
* for the 18+ gate, but PERSISTED at profile creation: a Firestore write issued immediately after
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules). So
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
* once the session has settled and skips re-asking. Process death clears this; CreateProfile then
* re-asks via its own DOB step (the gate still holds).
* Handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up for
* the 18+ gate but PERSISTED at profile creation: a Firestore write issued immediately after
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules).
* Sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel]
* writes it once the session has settled and skips re-asking.
*
* Backed by the app's Preferences DataStore (not memory) so it survives process death between
* sign-up and profile completion the in-memory version re-asked for the DOB after any restart
* mid-onboarding. Keyed per-uid so one account's DOB can never leak into another's profile.
*/
@Singleton
class SignupHandoff @Inject constructor() {
var pendingBirthDate: Long? = null
class SignupHandoff @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private fun keyFor(uid: String) = longPreferencesKey("pending_birth_date_" + uid)
suspend fun setPendingBirthDate(uid: String, birthDate: Long) {
dataStore.edit { it[keyFor(uid)] = birthDate }
}
/** The stashed DOB for this account, or null if none / already consumed. */
suspend fun pendingBirthDate(uid: String): Long? =
dataStore.data.map { it[keyFor(uid)] }.first()
suspend fun clear(uid: String) {
dataStore.edit { it.remove(keyFor(uid)) }
}
}

View File

@ -25,6 +25,8 @@ import java.time.LocalDate
* @property badge small celebratory indicator shown when the challenge is complete, or null
* @property canAdvance true when the user can mark today's step complete
* @property missedDate the local date of a detected missed day, if any
* @property statusDay the calendar-actionable challenge day (days since start, capped) when
* [currentDay] is ahead of it the user has finished today and the next day hasn't unlocked yet
*/
data class ChallengeState(
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
@ -40,7 +42,8 @@ data class ChallengeState(
val cta: String? = null,
val badge: String? = null,
val canAdvance: Boolean = false,
val missedDate: LocalDate? = null
val missedDate: LocalDate? = null,
val statusDay: Int = 1
)
/**

View File

@ -15,5 +15,9 @@ data class QuestionSession(
val completedByUsers: List<String> = emptyList(),
/** Members other than the starter who have opened/joined this active session. Drives the
* `partner_joined_game` notification to the starter. */
val joinedByUsers: List<String> = emptyList()
val joinedByUsers: List<String> = emptyList(),
/** True once the server recorded a first finished part (partFinishNotifiedAt set) i.e.
* ONE side has submitted their answers. Who finished isn't recorded; for the non-starter
* it is almost always the starter (they play at creation). */
val partHasFinished: Boolean = false
)

View File

@ -61,9 +61,16 @@ class DailyQuestionResolver @Inject constructor(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
// PAIRED FALLBACK MUST BE PREMIUM-INDEPENDENT (DQ-MISMATCH-001). The premium flag flips
// the selection POOL, and each partner resolves it independently at a different moment
// (per-user entitlement doc, transient read failures default to false, mid-day unlocks).
// With the pool differing, the "deterministic, identical on both devices" promise breaks:
// partners answer DIFFERENT questions and the reveal cross-compares them (and renders the
// partner's raw option id, since it isn't in this device's question config). Always fall
// back to the free pool for a couple — it is the only pool both devices agree on.
val question = if (assignment != null) {
questionRepository.getQuestionById(assignment.questionId)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
?: questionRepository.getDailyQuestion()
} else {
// No assignment yet — request one, but stay usable with the deterministic local
@ -74,7 +81,7 @@ class DailyQuestionResolver @Inject constructor(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
?: questionRepository.getDailyQuestion()
}

View File

@ -14,13 +14,17 @@ import javax.inject.Singleton
* auto-displays the push and this monitor isn't consulted which is the desired behaviour.
*/
@Singleton
class ActiveGameSessionMonitor @Inject constructor() {
class ActiveGameSessionMonitor @Inject constructor(
private val gamePromptController: GamePromptController
) {
@Volatile
var activeSessionId: String? = null
private set
fun enter(sessionId: String) {
if (sessionId.isNotBlank()) activeSessionId = sessionId
// Being on the session's screen makes any banner about it redundant (BANNER-LIFE-001).
gamePromptController.consumeForSession(sessionId)
}
fun leave(sessionId: String) {

View File

@ -47,13 +47,27 @@ class GamePromptController @Inject constructor() {
) {
if (coupleId.isBlank() || gameType.isBlank()) return
// Don't let a transient presence nudge (started/joined) clobber a persistent banner
// (your turn / results) the user hasn't acted on yet.
// (your turn / results) the user hasn't acted on yet — unless the nudge is about a
// DIFFERENT session, in which case the old banner is stale (that game already moved on)
// and the new activity wins (BANNER-LIFE-001).
val current = _prompt.value
if (current != null && current.kind.persistent && !kind.persistent) return
val sameSession = current?.gameSessionId != null && current.gameSessionId == gameSessionId
if (current != null && current.kind.persistent && !kind.persistent && sameSession) return
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
}
fun dismiss() {
_prompt.value = null
}
/**
* The user has reached this session's screen on their own (auto-flip reveal, replay, join)
* any banner still pointing at it is redundant, so retire it (BANNER-LIFE-001). Banners for
* OTHER sessions are left alone.
*/
fun consumeForSession(sessionId: String) {
if (sessionId.isBlank()) return
val current = _prompt.value ?: return
if (current.gameSessionId == sessionId) _prompt.value = null
}
}

View File

@ -952,12 +952,19 @@ private fun WaitingForPartnerCard() {
}
}
/** Last-resort readability for a raw option id ("a_comfort_show_vote" "A comfort show vote") so a
* slug never renders verbatim when the id isn't found in this device's question config the same
* guard How Well grew for HW-BREAKDOWN-001. */
private fun humanizeOptionId(id: String): String =
id.replace('_', ' ').trim().replaceFirstChar { it.uppercase() }
fun LocalAnswer.revealSummary(): String {
return when (answerType) {
"written" -> writtenText.orEmpty()
"scale" -> "You chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds }
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}
@ -978,7 +985,7 @@ private fun LocalAnswer.partnerRevealSummary(): String {
"written" -> writtenText.orEmpty()
"scale" -> "They chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds }
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}

View File

@ -46,6 +46,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -201,22 +202,17 @@ fun SignUpScreen(
val dobLabel = state.birthDate?.let {
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
} ?: ""
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val openDobPicker = {
focusManager.clearFocus()
val initMillis = state.birthDate
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
android.app.DatePickerDialog(
context,
{ _, y, m, d ->
viewModel.updateBirthDate(
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
)
},
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH),
c.get(java.util.Calendar.DAY_OF_MONTH)
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
showDobPicker = true
}
if (showDobPicker) {
app.closer.ui.components.DobPickerDialog(
initialSelectedDateMillis = state.birthDate,
onConfirm = viewModel::updateBirthDate,
onDismiss = { showDobPicker = false }
)
}
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(

View File

@ -71,7 +71,9 @@ class SignUpViewModel @Inject constructor(
// Firestore write issued right after signUpWithEmail races the auth-token attachment
// and silently fails rules. CreateProfile writes it once the session has settled and
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
signupHandoff.pendingBirthDate = dob
authRepository.currentUserId?.let { uid ->
signupHandoff.setPendingBirthDate(uid, dob)
}
_uiState.update { it.copy(isLoading = false, success = true) }
}
.onFailure { e ->

View File

@ -445,7 +445,10 @@ private fun ChallengePickCard(
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
if (challenge.isPremium) {
// Hide the 🔒 Premium badge once the couple has premium access — locked
// hints on owned features read as broken entitlements (A-003 pattern,
// matches the Play hub's showPremiumBadge = !hasPremium).
if (challenge.isPremium && !hasPremium) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.Gold.copy(alpha = 0.15f)
@ -527,6 +530,10 @@ private fun ChallengesActiveScreen(
val stateCopy = cs?.copy ?: ""
val ctaLabel: String? = cs?.cta
val missedDay = cs?.missedDate
// currentDay advances to the NEXT day the moment today's step is done, but that day only
// becomes actionable tomorrow (statusDay = calendar day). Don't spoil its prompt early —
// the daily unlock is the ritual.
val dayUnlocked = cs == null || currentDay <= cs.statusDay
// Route each CTA to its correct action.
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
@ -654,12 +661,14 @@ private fun ChallengesActiveScreen(
)
}
Text(
text = dayPrompt.prompt,
text = if (dayUnlocked) dayPrompt.prompt
else "Day $currentDay unlocks tomorrow \uD83C\uDF19",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurface,
color = if (dayUnlocked) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
)
if (dayPrompt.hint.isNotBlank()) {
if (dayUnlocked && dayPrompt.hint.isNotBlank()) {
Text(
text = dayPrompt.hint,
style = MaterialTheme.typography.bodySmall,

View File

@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.ui.brand.CloserBrandCopy
import kotlinx.coroutines.delay
@ -79,7 +80,10 @@ fun BrandMessageRotator(
style = style,
color = color,
textAlign = TextAlign.Center,
maxLines = 3
// The primary privacy message wraps to 4 lines at default font scale on the
// welcome/splash width; 3 clipped it mid-sentence with no ellipsis (AUTH-TRUNC-001).
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -0,0 +1,54 @@
package app.closer.ui.components
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import java.util.Calendar
/**
* Date-of-birth picker used by sign-up and profile creation. Compose M3 so it renders in the
* app's palette in both themes the previous View-based android.app.DatePickerDialog fell back
* to the platform's default teal because no dialog theme overlay was defined.
* Dates after today are not selectable (a birth date is always in the past).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DobPickerDialog(
initialSelectedDateMillis: Long?,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
val now = System.currentTimeMillis()
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val state = rememberDatePickerState(
initialSelectedDateMillis = initialSelectedDateMillis
?: Calendar.getInstance().apply { add(Calendar.YEAR, -18) }.timeInMillis,
initialDisplayMode = DisplayMode.Picker,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis <= now
override fun isSelectableYear(year: Int): Boolean = year <= currentYear
}
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
state.selectedDateMillis?.let(onConfirm)
onDismiss()
}
) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
) {
DatePicker(state = state, showModeToggle = false)
}
}

View File

@ -149,10 +149,24 @@ private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
return when (prompt.kind) {
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play")
GamePromptKind.YOUR_TURN -> PromptStyle(
"$name played their part",
when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL -> "Your turn — guess their answers"
app.closer.domain.model.GameType.DESIRE_SYNC -> "Your turn — only mutual yeses ever show"
app.closer.domain.model.GameType.THIS_OR_THAT -> "Your turn — see where you line up"
else -> "Your turn — reveal how you line up"
},
"Play"
)
GamePromptKind.RESULTS -> PromptStyle(
"You both finished",
if (resolved != null) "See how you and $resolved compare" else "See how you both compare",
when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL ->
if (resolved != null) "See how well you know $resolved" else "See how well you know each other"
app.closer.domain.model.GameType.DESIRE_SYNC -> "See what you both said yes to"
else -> if (resolved != null) "See how you and $resolved compare" else "See how you both compare"
},
"View"
)
}

View File

@ -341,7 +341,8 @@ private fun DateCard(
modifier = modifier,
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 0.96f)
// Top card must be fully opaque or the next card's text bleeds through it.
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 1f)
),
elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp)
) {
@ -585,7 +586,7 @@ private fun MatchOverlay(
)
Text(
text = "It is a match!",
text = "Its a match!",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)

View File

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.random.Random
data class DateMatchUiState(
val isLoading: Boolean = true,
@ -39,6 +40,8 @@ data class DateMatchUiState(
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
val matches: List<DateMatch> = emptyList(),
val currentIndex: Int = 0,
/** Set once the initial deck has been filtered against this couple's prior swipes. */
val swipeFilterApplied: Boolean = false,
val justMatched: DateMatch? = null,
val hasPremium: Boolean = false
) {
@ -87,7 +90,11 @@ class DateMatchViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull()
}
val ideas = repository.getDateIdeas()
// Deterministic per-couple order: kills the "every couple's first card is the
// same seed idea" determinism, while staying stable across reloads for one couple.
val ideas = couple?.id
?.let { repository.getDateIdeas().shuffled(Random(it.hashCode())) }
?: repository.getDateIdeas()
_uiState.value = DateMatchUiState(
isLoading = false,
coupleId = couple?.id,
@ -124,9 +131,20 @@ class DateMatchViewModel @Inject constructor(
matches.firstOrNull { it.id !in known }
}
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
val swipeMap = swipes.associate { it.dateIdeaId to it.action }
_uiState.update { state ->
// First time swipes arrive, resume the deck PAST ideas already swiped so
// they don't reappear on every reopen (deck used to restart at index 0).
val (deck, index, applied) = if (!state.swipeFilterApplied && swipeMap.isNotEmpty()) {
Triple(state.dateIdeas.filterNot { it.id in swipeMap.keys }, 0, true)
} else {
Triple(state.dateIdeas, state.currentIndex, state.swipeFilterApplied || swipeMap.isEmpty().not())
}
state.copy(
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
dateIdeas = deck,
currentIndex = index,
swipeFilterApplied = applied,
ownSwipes = swipeMap,
matches = matches,
justMatched = newMatch ?: state.justMatched
)

View File

@ -78,6 +78,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import app.closer.ui.components.CloserGlyphs
@ -102,6 +103,7 @@ data class DesireSyncUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -122,7 +124,8 @@ class DesireSyncViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val premiumChecker: CouplePremiumChecker,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor,
private val seenQuestionStore: app.closer.data.local.SeenDesireQuestionStore
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -194,8 +197,19 @@ class DesireSyncViewModel @Inject constructor(
/** First partner: pick the neutral question set and open the shared session. */
private suspend fun createSession(uid: String) {
val questions = loadNeutralQuestions().shuffled().take(_uiState.value.selectedLength.count)
val count = _uiState.value.selectedLength.count
val pool = loadNeutralQuestions()
// Prefer prompts this couple hasn't seen so back-to-back rounds don't repeat. When the
// unseen remainder can't fill a round, wipe the history and cycle the whole pool afresh.
val cId = coupleId
val seen = if (cId != null) runCatching { seenQuestionStore.seen(cId) }.getOrDefault(emptySet()) else emptySet()
val unseen = pool.filterNot { it.id in seen }
val source = if (unseen.size >= count) unseen else pool.also {
if (cId != null) runCatching { seenQuestionStore.clear(cId) }
}
val questions = source.shuffled().take(count)
if (questions.isEmpty()) return fail("No questions available.")
if (cId != null) runCatching { seenQuestionStore.markSeen(cId, questions.map { it.id }) }
val startResult = runCatching {
gameSessionManager.startGame(
@ -301,6 +315,18 @@ class DesireSyncViewModel @Inject constructor(
// The observer flips WAITING → REVEAL once the partner's answers land.
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) DesireSyncPhase.WAITING else DesireSyncPhase.ANSWER,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
val answers = _uiState.value.myAnswers
if (answers.isEmpty()) return
@ -314,7 +340,14 @@ class DesireSyncViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId).collect { answers ->
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -427,7 +460,7 @@ fun DesireSyncScreen(
DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
)
DesireSyncPhase.SETUP -> DSSetupScreen(
selectedLength = state.selectedLength,
@ -460,7 +493,9 @@ fun DesireSyncScreen(
total = state.questions.size,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
}
@ -772,7 +807,9 @@ private fun DSRevealScreen(
total: Int,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -860,6 +897,9 @@ private fun DSRevealScreen(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -4,4 +4,7 @@ package app.closer.ui.games
object GameCopy {
/** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */
const val SAVE_ANSWERS_ERROR = "Couldn't save your answers. Check your connection and try again."
/** Shown when the live partner-answers listener fails (network/permissions) — retry re-attaches it. */
const val SYNC_ERROR = "Couldn't check your partner's progress. Check your connection and try again."
}

View File

@ -0,0 +1,124 @@
package app.closer.ui.games
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.usecase.DailyQuestionResolver
import app.closer.ui.components.CloserGlyphs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* The "what's next" beat shown under a game's results CTAs, so finishing a game doesn't dead-end.
* Priority: today's daily question if it's still unanswered (the return anchor), otherwise a
* gentle streak note. Self-contained its own ViewModel resolves the state so game screens only
* drop in the composable and provide navigation.
*/
sealed interface NextBeat {
data object None : NextBeat
data object DailyReady : NextBeat
data class Streak(val days: Int) : NextBeat
}
@HiltViewModel
class NextBeatViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val dailyQuestionResolver: DailyQuestionResolver,
private val localAnswerRepository: LocalAnswerRepository,
) : ViewModel() {
private val _beat = MutableStateFlow<NextBeat>(NextBeat.None)
val beat: StateFlow<NextBeat> = _beat.asStateFlow()
init {
viewModelScope.launch {
val resolved = runCatching { dailyQuestionResolver.resolve() }.getOrNull()
val todayQid = resolved?.question?.id
val answered = todayQid != null && runCatching {
localAnswerRepository.observeAnswers().first().any { it.questionId == todayQid }
}.getOrDefault(true) // fail safe: assume answered → don't nag on error
if (todayQid != null && !answered) {
_beat.value = NextBeat.DailyReady
return@launch
}
val streak = runCatching {
authRepository.currentUserId
?.let { coupleRepository.getCoupleForUser(it)?.streakCount }
}.getOrNull() ?: 0
if (streak > 0) _beat.value = NextBeat.Streak(streak)
}
}
}
@Composable
fun NextBeatCard(
onOpenDaily: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NextBeatViewModel = hiltViewModel(),
) {
val beat by viewModel.beat.collectAsStateWithLifecycle()
when (val b = beat) {
is NextBeat.None -> Unit
is NextBeat.DailyReady -> Surface(
onClick = onOpenDaily,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Tonight's question is ready",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.weight(1f)
)
Icon(
CloserGlyphs.Forward,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
is NextBeat.Streak -> Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = modifier.fillMaxWidth()
) {
Text(
text = "Day ${b.days} together — see you tomorrow 💜",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
)
}
}
}

View File

@ -1,5 +1,11 @@
package app.closer.ui.games
import androidx.compose.ui.platform.LocalContext
import android.widget.Toast
import android.util.Log
import kotlinx.coroutines.tasks.await
import com.google.firebase.functions.FirebaseFunctionsException
import com.google.firebase.functions.FirebaseFunctions
import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -16,6 +22,7 @@ import androidx.compose.material3.Button
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -59,12 +66,17 @@ data class WaitingForPartnerUiState(
* 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
val joinRoute: String? = null,
/** True when the partner already submitted their part — this user isn't waiting, it's their turn. */
val partnerFinished: Boolean = false,
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null
)
@HiltViewModel
class WaitingForPartnerViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager
private val gameSessionManager: GameSessionManager,
private val functions: FirebaseFunctions
) : ViewModel() {
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
@ -94,7 +106,11 @@ class WaitingForPartnerViewModel @Inject constructor(
isLoading = false,
gameType = session.gameType,
partnerName = partnerName,
joinRoute = gameTypeRoute(session.gameType)
joinRoute = gameTypeRoute(session.gameType),
// "Your turn" only for the NON-starter once a first part landed —
// the starter plays at creation, so for the joiner that part is the
// partner's. (completedByUsers only fills at reveal, too late here.)
partnerFinished = session.partHasFinished && userId != session.startedByUserId
)
}
}
@ -102,6 +118,32 @@ class WaitingForPartnerViewModel @Inject constructor(
}
}
/**
* Nudge the partner while waiting on them reuses the generic thinking-of-you callable
* (10/day, quiet-hours-aware server-side). Fails gracefully to a one-shot message.
*/
fun sendNudge() {
if (_uiState.value.isSendingNudge) return
_uiState.update { it.copy(isSendingNudge = true) }
viewModelScope.launch {
val message = runCatching {
functions.getHttpsCallable("sendThinkingOfYouCallable").call().await()
}.fold(
onSuccess = { "Sent 💜" },
onFailure = { e ->
Log.w("WaitingForPartner", "nudge failed", e)
if ((e as? FirebaseFunctionsException)?.code ==
FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED
) "You've nudged a few times — give it a moment 💜"
else "Couldn't send right now. Try again."
}
)
_uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) }
}
}
fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) }
/** Force-end the partner's active session so this user can start a new game. */
fun abandonPartnerGame() {
val cId = coupleId ?: return
@ -132,6 +174,14 @@ fun WaitingForPartnerScreen(
}
}
val context = LocalContext.current
LaunchedEffect(state.nudgeResult) {
state.nudgeResult?.let {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
viewModel.consumeNudgeResult()
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -176,14 +226,21 @@ fun WaitingForPartnerScreen(
}
Text(
text = "Waiting for ${state.partnerName}",
// "Waiting for X" is wrong once X already played — then it's THIS
// user's move and the screen should say so.
text = if (state.partnerFinished) "Your turn"
else "Waiting for ${state.partnerName}",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
textAlign = TextAlign.Center
)
Text(
text = "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game.",
text = if (state.partnerFinished) {
"${state.partnerName} already played their part of ${gameTypeLabel(state.gameType)} — jump in to see how you compare."
} else {
"${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game."
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
@ -220,6 +277,15 @@ fun WaitingForPartnerScreen(
Text("Back to Games")
}
}
if (!state.partnerFinished) {
OutlinedButton(
onClick = viewModel::sendNudge,
enabled = !state.isSendingNudge,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isSendingNudge) "Sending…" else "Send a little nudge 💜")
}
}
TextButton(onClick = viewModel::abandonPartnerGame) {
Text(
"End their game",

View File

@ -1015,6 +1015,8 @@ class HomeViewModel @Inject constructor(
private fun String.toHomeLabel(): String =
split("_", "-")
.filter { part -> part.isNotBlank() }
// Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users.
.filterNot { part -> part.lowercase() in setOf("mc") }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
companion object {

View File

@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.draw.clip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -85,6 +87,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import app.closer.ui.components.CloserGlyphs
@ -149,6 +152,7 @@ data class HowWellUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -333,6 +337,18 @@ class HowWellViewModel @Inject constructor(
// The observer flips WAITING → COMPLETE once the partner's answers land.
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) HowWellPhase.WAITING else HowWellPhase.ANSWER,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
if (myAnswers.isEmpty()) return
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
@ -345,7 +361,14 @@ class HowWellViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId).collect { answers ->
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -427,6 +450,23 @@ class HowWellViewModel @Inject constructor(
load()
}
/**
* Newlywed-style role swap: the guesser starts a new round as the SUBJECT (starter == subject),
* so now their partner guesses them. Skips the length picker by reusing the last length.
*/
fun startSwapped() {
val len = _uiState.value.selectedLength
observeJob?.cancel()
sessionId = null
startedByUserId = null
submitted = false
myAnswers.clear()
_uiState.value = HowWellUiState(phase = HowWellPhase.LOADING, selectedLength = len)
val uid = userId
if (uid == null) { fail("You need to be signed in to play."); return }
viewModelScope.launch { createSession(uid) }
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
@ -468,7 +508,7 @@ fun HowWellScreen(
HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
)
HowWellPhase.SETUP -> HWSetupScreen(
selectedLength = state.selectedLength,
@ -511,7 +551,10 @@ fun HowWellScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onSwapRoles = viewModel::startSwapped,
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
}
@ -856,7 +899,10 @@ private fun CompleteScreen(
amSubject: Boolean,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onSwapRoles: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -910,17 +956,36 @@ private fun CompleteScreen(
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
// The guesser just tried to read their partner — offer the natural next beat:
// swap so the partner now guesses THEM (Newlywed-style role reversal).
if (!amSubject) {
Button(
onClick = onSwapRoles,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Your turn — let $partnerName guess you", color = Color.White) }
OutlinedButton(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Play again") }
} else {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}
@ -955,7 +1020,9 @@ private fun HowWellScoreRing(
) {
val progress = if (total == 0) 0f else score.toFloat() / total
Box(
modifier = modifier,
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "You guessed $score of $total correctly"
},
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
@ -993,14 +1060,19 @@ private fun HowWellScoreRing(
@Composable
private fun BreakdownRow(result: HowWellResult) {
val matchColor = Color(0xFF2E7D32)
val closeColor = Color(0xFFF57F17)
val missColor = Color(0xFFC62828)
// Match-row colors come as a container+content PAIR per theme: the old fixed pale-green
// container kept theme-driven onSurfaceVariant text, which is near-invisible in dark mode.
val dark = androidx.compose.foundation.isSystemInDarkTheme()
val matchColor = if (dark) Color(0xFF81C995) else Color(0xFF2E7D32)
val closeColor = if (dark) Color(0xFFF6C453) else Color(0xFFF57F17)
val missColor = if (dark) Color(0xFFE58A82) else Color(0xFFC62828)
val matchContainer = if (dark) Color(0xFF223A2A) else Color(0xFFE8F5E9)
val questionColor = if (result.isMatch && dark) Color(0xFFB9CBBE) else MaterialTheme.colorScheme.onSurfaceVariant
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
containerColor = if (result.isMatch) matchContainer else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(2.dp)
) {
@ -1018,7 +1090,7 @@ private fun BreakdownRow(result: HowWellResult) {
Text(
text = result.question.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = questionColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -1287,6 +1359,7 @@ fun HowWellReplayScreen(
amSubject = phase.amSubject,
partnerName = state.partnerName,
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
onSwapRoles = { onNavigate(AppRoute.HOW_WELL) },
onHome = { onNavigate(AppRoute.PLAY) }
)
}

View File

@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -173,22 +174,17 @@ fun CreateProfileScreen(
)
Spacer(Modifier.height(24.dp))
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val openDobPicker = {
focusManager.clearFocus()
val initMillis = state.birthDate
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
android.app.DatePickerDialog(
context,
{ _, y, m, d ->
viewModel.selectBirthDate(
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
)
},
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH),
c.get(java.util.Calendar.DAY_OF_MONTH)
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
showDobPicker = true
}
if (showDobPicker) {
app.closer.ui.components.DobPickerDialog(
initialSelectedDateMillis = state.birthDate,
onConfirm = viewModel::selectBirthDate,
onDismiss = { showDobPicker = false }
)
}
when (state.currentStep) {

View File

@ -64,7 +64,7 @@ class CreateProfileViewModel @Inject constructor(
// Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory,
// since the post-signup write is unreliable). Require the DOB step only when we have neither
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate(uid)
val needsDob = effectiveDob == null
_uiState.update {
it.copy(
@ -189,7 +189,7 @@ class CreateProfileViewModel @Inject constructor(
}
}.onSuccess {
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
signupHandoff.pendingBirthDate = null
signupHandoff.clear(uid)
_uiState.update { it.copy(isLoading = false, success = true) }
}.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }

View File

@ -337,6 +337,8 @@ private fun answerSummary(question: Question, state: LocalQuestionUiState): Stri
fun String.displayCategoryName(): String {
return split("_", "-")
.filter { it.isNotBlank() }
// Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users.
.filterNot { it.lowercase() == "mc" }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
}

View File

@ -76,44 +76,8 @@ import app.closer.ui.settings.SettingsPrimaryDeep
import app.closer.ui.components.CloserGlyphs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditProfileScreen(
onNavigate: (String) -> Unit = {},
viewModel: EditProfileViewModel = hiltViewModel()
) {
val snackbar = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(SettingsBackgroundBrush),
topBar = {
TopAppBar(
title = { Text("Edit profile", color = SettingsInk) },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(
CloserGlyphs.Back,
contentDescription = "Back",
tint = SettingsInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
EditProfileContent(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
snackbar = snackbar,
viewModel = viewModel
)
}
}
// The routed EditProfileScreen wrapper was removed as dead code (no nav route references
// it); AccountScreen embeds EditProfileContent directly.
@Composable
fun EditProfileContent(
modifier: Modifier = Modifier,

View File

@ -1,20 +0,0 @@
package app.closer.ui.theme
import androidx.compose.ui.graphics.Color
// Dark theme colors (for reference if needed)
val darkPrimaryColor = Color(0xFFD7BBFF)
val darkPrimaryContainerColor = Color(0xFF4D3865)
val darkSecondaryColor = Color(0xFFE8B7D0)
val darkTertiaryColor = Color(0xFFC9C1FF)
val darkBackgroundColor = Color(0xFF111015)
val darkSurfaceColor = Color(0xFF19161F)
val darkErrorColor = Color(0xFFFFB4AB)
val darkOnPrimaryColor = Color(0xFF37204E)
val darkOnPrimaryContainerColor = Color(0xFFF2E8FF)
val darkOnSecondaryColor = Color(0xFF482638)
val darkOnTertiaryColor = Color(0xFF302A55)
val darkOnBackgroundColor = Color(0xFFEDE7EF)
val darkOnSurfaceColor = Color(0xFFEDE7EF)
val darkOnErrorColor = Color(0xFF690005)

View File

@ -51,6 +51,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -96,6 +98,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -150,6 +153,7 @@ data class ThisOrThatUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -302,7 +306,14 @@ class ThisOrThatViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId).collect { answers ->
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = TotPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -421,6 +432,18 @@ class ThisOrThatViewModel @Inject constructor(
}
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) TotPhase.WAITING else TotPhase.PLAYING,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
val answers = _uiState.value.myAnswers
if (answers.isEmpty()) return
@ -478,7 +501,7 @@ fun ThisOrThatScreen(
TotPhase.ERROR -> ErrorState(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
)
TotPhase.WAITING -> WaitingForRevealScreen(
partnerName = state.partnerName,
@ -497,7 +520,9 @@ fun ThisOrThatScreen(
partnerName = state.partnerName,
cards = state.revealCards,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
TotPhase.PLAYING -> {
val question = state.questions.getOrNull(state.currentIndex)
@ -1035,7 +1060,9 @@ private fun ThisOrThatReveal(
partnerName: String,
cards: List<RevealCard>,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
val ratio = if (total > 0) matched.toFloat() / total else 0f
val headline = when {
@ -1102,6 +1129,10 @@ private fun ThisOrThatReveal(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}
@ -1110,7 +1141,11 @@ private fun ThisOrThatReveal(
private fun MatchScoreBadge(matched: Int, total: Int) {
val cs = MaterialTheme.colorScheme
Surface(
modifier = Modifier.size(116.dp),
modifier = Modifier
.size(116.dp)
.semantics(mergeDescendants = true) {
contentDescription = "You matched on $matched of $total"
},
shape = CircleShape,
color = cs.primaryContainer,
shadowElevation = 8.dp

View File

@ -63,7 +63,7 @@ fun CategoryPickerScreen(
if (item.isLocked) onNavigate(AppRoute.PAYWALL)
else onNavigate(AppRoute.spinWheel(item.category.id))
},
onHistory = { onNavigate(AppRoute.WHEEL_HISTORY) },
onHistory = { onNavigate(AppRoute.GAME_HISTORY) },
onRetry = viewModel::load
)
}

View File

@ -215,7 +215,7 @@ class SpinWheelViewModel @Inject constructor(
}
fun onHistory() {
_uiState.update { it.copy(navigateTo = AppRoute.WHEEL_HISTORY) }
_uiState.update { it.copy(navigateTo = AppRoute.GAME_HISTORY) }
}
fun onNavigated() {

View File

@ -237,7 +237,9 @@ fun WheelCompleteScreen(
partnerName = state.partnerName,
items = state.items,
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
onHome = { onNavigate(AppRoute.PLAY) }
onHome = { onNavigate(AppRoute.PLAY) },
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
WheelRevealPhase.ERROR -> WheelErrorContent(
message = state.error ?: "Something went wrong.",
@ -324,7 +326,9 @@ private fun WheelRevealContent(
partnerName: String,
items: List<WheelRevealItem>,
onSpinAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -390,6 +394,10 @@ private fun WheelRevealContent(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -274,6 +274,43 @@ class ChallengeStateMachineTest {
assertEquals(3, state.currentDay)
}
// -----------------------------------------------------------------
// statusDay exposure (day-unlock gating for the UI)
// -----------------------------------------------------------------
@Test
fun `statusDay stays on the calendar day when user races ahead same-day`() {
// Started today, completed day 1 already: currentDay advances to 2 (next incomplete)
// but statusDay stays 1 — the UI uses currentDay > statusDay to hide day 2's prompt.
val startedAt = today.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt, mine = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(2, state.currentDay)
assertEquals(1, state.statusDay)
}
@Test
fun `statusDay advances with the calendar`() {
val startedAt = today.minusDays(2).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt, mine = listOf(1, 2, 3)),
today = today
)
val state = ChallengeStateMachine.compute(input)
// Day 3 of the calendar; user already did day 3 → next (4) is tomorrow's.
assertEquals(4, state.currentDay)
assertEquals(3, state.statusDay)
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------

View File

@ -157,7 +157,12 @@ exports.onGameSessionUpdate = functions.firestore
return;
}
// ── Session completed (reveal ready for both) ────────────────────────
if (status === 'completed' && !currentData.finishNotifiedAt) {
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : [];
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
@ -240,8 +245,20 @@ exports.onGamePartFinished = functions.firestore
const finisher = await db.collection('users').doc(finisherUid).get();
const finisherName = 'Your partner'; // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId);
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
});
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType) {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers';
case 'desire_sync': return 'Your turn — only mutual yeses ever show';
case 'this_or_that': return 'Your turn — see where you line up';
default: return 'Your turn — reveal how you line up';
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/

File diff suppressed because one or more lines are too long

View File

@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const CST_OFFSET_HOURS = -6;
/**
* Scheduled function that assigns one daily question per couple every day.
*
@ -57,9 +59,9 @@ exports.assignDailyQuestion = functions.pubsub
const db = admin.firestore();
const today = cstDateString();
const nextDay = nextCstDateString();
const questionId = await pickRandomQuestionId();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
console.error('[assignDailyQuestion] no active questions available');
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
return;
}
const couplesSnap = await db.collection('couples').get();
@ -135,7 +137,7 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
}
const questionId = await pickRandomQuestionId();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.');
}
@ -173,20 +175,51 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
async function pickRandomQuestionId() {
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest', // Saturday — Side Quest Saturday
};
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr) {
const db = admin.firestore();
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get();
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily';
}
const docs = snapshot.docs;
const chosen = docs[Math.floor(Math.random() * docs.length)];
return chosen.id;
if (snapshot.empty)
return null;
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay();
const modeTag = WEEKDAY_MODE_TAGS[weekday];
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // wildcard/empty-mode fallback
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id;
}
/**
* Returns today's date as YYYY-MM-DD in America/Chicago time.
@ -203,35 +236,47 @@ function nextCstDateString(from) {
d.setUTCDate(d.getUTCDate() + 1);
return formatCstDate(d);
}
const d = new Date();
const cst = applyCstOffset(d);
cst.setUTCDate(cst.getUTCDate() + 1);
return formatCstDate(cst);
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()));
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
return formatCstDate(todayChicago);
}
function applyCstOffset(d) {
const utcMs = d.getTime();
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000;
return new Date(cstMs);
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs));
const get = (t) => { var _a; return Number((_a = parts.find((p) => p.type === t)) === null || _a === void 0 ? void 0 : _a.value); };
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'));
return Math.round((atUtcMs - localAsUtcMs) / 3600000);
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
function formatCstDate(d) {
const cst = applyCstOffset(d);
const y = cst.getUTCFullYear();
const m = String(cst.getUTCMonth() + 1).padStart(2, '0');
const day = String(cst.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
function parseCstDate(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0);
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000);
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6);
const offset = chicagoOffsetHours(guessMs);
return new Date(Date.UTC(y, m - 1, day, offset));
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
function timestampAt6PmCst(dateStr) {
const d = parseCstDate(dateStr);
const utcMs = d.getTime();
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
// We already have midnight CST in UTC form; add 18 hours.
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000;
return admin.firestore.Timestamp.fromMillis(at6pmCstMs);
const [y, m, day] = dateStr.split('-').map(Number);
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
const offset = chicagoOffsetHours(guessMs);
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
}
//# sourceMappingURL=assignDailyQuestion.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const assignDailyQuestion_1 = require("./assignDailyQuestion");
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07');
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08');
});
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07');
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08');
});
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z');
// Winter: midnight CST == 06:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z');
});
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z');
// Winter: 18:00 CST == 00:00Z next day
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z');
});
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect((0, assignDailyQuestion_1.formatCstDate)((0, assignDailyQuestion_1.parseCstDate)(day))).toBe(day);
}
});
});
//# sourceMappingURL=assignDailyQuestion.test.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":";;AAAA,+DAAsF;AAEtF,iFAAiF;AACjF,uEAAuE;AACvE,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E,gFAAgF;QAChF,uEAAuE;QACvE,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,oFAAoF;QACpF,gFAAgF;QAChF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,4CAA4C;QAC5C,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QACjF,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACnF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,8BAA8B;QAC9B,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QAC/F,uCAAuC;QACvC,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,KAAK,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YACzF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAA,kCAAY,EAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACpD,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -134,7 +134,12 @@ export const onGameSessionUpdate = functions.firestore
}
// ── Session completed (reveal ready for both) ────────────────────────
if (status === 'completed' && !currentData.finishNotifiedAt) {
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : []
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
@ -223,11 +228,24 @@ export const onGamePartFinished = functions.firestore
await notifyPartner(
db, messaging, recipient, finisherName, gameType,
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId,
'partner_completed_part', yourTurnBody(gameType), coupleId,
finisherAvatar, sessionId
)
})
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType: string): string {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers'
case 'desire_sync': return 'Your turn — only mutual yeses ever show'
case 'this_or_that': return 'Your turn — see where you line up'
default: return 'Your turn — reveal how you line up'
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/

View File

@ -0,0 +1,40 @@
import { formatCstDate, parseCstDate, timestampAt6PmCst } from './assignDailyQuestion'
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect(formatCstDate(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07')
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect(formatCstDate(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08')
})
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect(formatCstDate(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07')
expect(formatCstDate(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08')
})
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect(parseCstDate('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z')
// Winter: midnight CST == 06:00Z
expect(parseCstDate('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z')
})
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect(timestampAt6PmCst('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z')
// Winter: 18:00 CST == 00:00Z next day
expect(timestampAt6PmCst('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z')
})
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect(formatCstDate(parseCstDate(day))).toBe(day)
}
})
})

View File

@ -1,7 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const CST_OFFSET_HOURS = -6
/**
* Scheduled function that assigns one daily question per couple every day.
@ -24,9 +23,9 @@ export const assignDailyQuestion = functions.pubsub
const today = cstDateString()
const nextDay = nextCstDateString()
const questionId = await pickRandomQuestionId()
const questionId = await pickDailyQuestionId(today)
if (!questionId) {
console.error('[assignDailyQuestion] no active questions available')
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
return
}
@ -110,7 +109,7 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
}
const questionId = await pickRandomQuestionId()
const questionId = await pickDailyQuestionId(today)
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.')
}
@ -151,22 +150,53 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
async function pickRandomQuestionId(): Promise<string | null> {
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS: Record<number, string> = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest',// Saturday — Side Quest Saturday
}
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr: string): number {
const [y, m, d] = dateStr.split('-').map(Number)
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000)
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
const db = admin.firestore()
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get()
if (snapshot.empty) return null
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily'
}
const docs = snapshot.docs
const chosen = docs[Math.floor(Math.random() * docs.length)]
return chosen.id
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
const modeTag = WEEKDAY_MODE_TAGS[weekday]
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
return pool[offset].id
}
/**
@ -185,38 +215,50 @@ function nextCstDateString(from?: string): string {
d.setUTCDate(d.getUTCDate() + 1)
return formatCstDate(d)
}
const d = new Date()
const cst = applyCstOffset(d)
cst.setUTCDate(cst.getUTCDate() + 1)
return formatCstDate(cst)
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()))
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1)
return formatCstDate(todayChicago)
}
function applyCstOffset(d: Date): Date {
const utcMs = d.getTime()
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000
return new Date(cstMs)
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs))
const get = (t: string): number => Number(parts.find((p) => p.type === t)?.value)
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'))
return Math.round((atUtcMs - localAsUtcMs) / 3_600_000)
}
function formatCstDate(d: Date): string {
const cst = applyCstOffset(d)
const y = cst.getUTCFullYear()
const m = String(cst.getUTCMonth() + 1).padStart(2, '0')
const day = String(cst.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
export function formatCstDate(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d)
}
function parseCstDate(dateStr: string): Date {
/** The UTC instant of midnight America/Chicago on the given calendar date. */
export function parseCstDate(dateStr: string): Date {
const [y, m, day] = dateStr.split('-').map(Number)
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0)
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000)
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6)
const offset = chicagoOffsetHours(guessMs)
return new Date(Date.UTC(y, m - 1, day, offset))
}
function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
const d = parseCstDate(dateStr)
const utcMs = d.getTime()
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
// We already have midnight CST in UTC form; add 18 hours.
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000
return admin.firestore.Timestamp.fromMillis(at6pmCstMs)
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
export function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
const [y, m, day] = dateStr.split('-').map(Number)
const guessMs = Date.UTC(y, m - 1, day, 18 + 6)
const offset = chicagoOffsetHours(guessMs)
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset))
}

View File

@ -33,3 +33,8 @@ NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversat
- coupleId `Xal3Kw3gjSdn0niERYKJ`
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`.
## Fixture accounts & passwords
Live test-account emails, uids, AVD mapping, and passwords are kept OUT of this repo in the
gitignored `qa/fixtures.local.md` (dev-project throwaway creds). Ask the operator or read that
local file. Never commit credentials.