Compare commits
13 Commits
5013c2cd09
...
5d03fd98be
| Author | SHA1 | Date |
|---|---|---|
|
|
5d03fd98be | |
|
|
3acba138a3 | |
|
|
15e4579410 | |
|
|
ca67187754 | |
|
|
f2321d3536 | |
|
|
fda53175a9 | |
|
|
8e8223ca97 | |
|
|
cf8f82357e | |
|
|
9d0a0c7fdf | |
|
|
4ab79d34c4 | |
|
|
d554483ef2 | |
|
|
c36a101d38 | |
|
|
e5868bd6b1 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (60–75
|
||||
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 1–5 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).
|
||||
|
|
@ -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
15
Future.md
15
Future.md
|
|
@ -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
465
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) <> ''")
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -133,7 +133,8 @@ object ChallengeStateMachine {
|
|||
cta = cta,
|
||||
badge = null,
|
||||
canAdvance = canAdvance,
|
||||
missedDate = missedDate
|
||||
missedDate = missedDate,
|
||||
statusDay = statusDay
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "It’s a match!",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.") }
|
||||
|
|
|
|||
|
|
@ -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() } }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue