Compare commits
No commits in common. "5d03fd98bec39280298a386f94cbaa8e5fc60b7d" and "5013c2cd09bda333b56c9ea4d8a8e9fa113c7eea" have entirely different histories.
5d03fd98be
...
5013c2cd09
|
|
@ -91,7 +91,3 @@ docs/strategy/positioning-vs-paired.md
|
||||||
ClaudeQAPlan.md
|
ClaudeQAPlan.md
|
||||||
ClaudeReport.md
|
ClaudeReport.md
|
||||||
relationship-app.code-workspace
|
relationship-app.code-workspace
|
||||||
|
|
||||||
# QA fixtures with test credentials — local only, never commit
|
|
||||||
qa/fixtures.local.md
|
|
||||||
qa/*.local.md
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# 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,10 +1,6 @@
|
||||||
# Claude QA Coverage Matrix
|
# Claude QA Coverage Matrix
|
||||||
|
|
||||||
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
|
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
|
||||||
|
|
||||||
> **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.**
|
> **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.**
|
> **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).
|
> **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,21 +83,6 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
|
||||||
|
|
||||||
## QA
|
## 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).
|
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`
|
- **Tier 3: Compose screenshot diff for visual regression.** The static scanner in `scripts/theme-scan.sh`
|
||||||
|
|
|
||||||
465
README.md
465
README.md
|
|
@ -1,183 +1,269 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="920" />
|
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="860" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Closer</h1>
|
<h1 align="center">Closer</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>A private space for two.</strong><br />
|
<strong>A private space for two.</strong><br />
|
||||||
Daily questions, mutual reveals, shared games, and calm rituals for couples.
|
Private daily questions, intentional reveals, shared games, and calm rituals for couples.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#screenshots">Screenshots</a>
|
<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" />
|
||||||
<a href="#what-makes-it-different">What makes it different</a>
|
<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" />
|
||||||
<a href="#platform-status">Status</a>
|
<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" />
|
||||||
<a href="#local-development">Run locally</a>
|
<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="#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>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Closer is a native relationship app for couples who want a quieter way to check in with each other.
|
> **Private daily questions for couples — end-to-end encrypted, never read, never sold.**
|
||||||
Each partner answers privately, reveals intentionally, and keeps a record of the conversations that
|
> *You and your paired partner hold the only key.*
|
||||||
matter.
|
|
||||||
|
|
||||||
It is not a social network, therapy replacement, or productivity tracker. There are no public feeds,
|
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**.
|
||||||
likes, followers, or infinite-scroll loops. The product loop is small on purpose:
|
|
||||||
|
|
||||||
```text
|
Not a social network. Not therapy. Not a productivity tracker. **No public feeds, no likes, no followers, no infinite scroll.**
|
||||||
answer honestly -> reveal together -> keep the conversation going
|
|
||||||
```
|
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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Fresh Android dark-mode captures from the current emulator build.
|
Fresh Android dark-mode captures from the current emulator build.
|
||||||
|
|
||||||
<table>
|
Daily question stages on Home:
|
||||||
<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>
|
|
||||||
|
|
||||||
<table>
|
| Question Ready | Your Turn | Reveal Ready |
|
||||||
<tr>
|
| :---: | :---: | :---: |
|
||||||
<td align="center"><strong>Play</strong></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="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" /> |
|
||||||
<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>
|
|
||||||
|
|
||||||
## What makes it different
|
Other core screens:
|
||||||
|
|
||||||
<table>
|
| Play | This or That | Today | Challenge |
|
||||||
<tr>
|
| :---: | :---: | :---: | :---: |
|
||||||
<td width="33%"><strong>Private first</strong><br />Partners answer independently before seeing each other's response.</td>
|
| <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" /> |
|
||||||
<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
|
---
|
||||||
|
|
||||||
| Area | Free | Premium |
|
## 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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Daily question | Included | Included |
|
| Daily question (text / scale / multi / this-or-that) | ✅ | ✅ |
|
||||||
| Private answers + mutual reveal | Included | Included |
|
| 6,000+ prompts · 22 question packs | ✅ (free) + 🎟️ (premium tiers) | ✅ (incl. premium-only packs) |
|
||||||
| Curated question packs | Free + mixed access | Full access |
|
| Private answers + mutual-reveal flow | ✅ | ✅ |
|
||||||
| Spin the Wheel | Included | Included |
|
| Spin the wheel — category-randomized questions | ✅ | ✅ |
|
||||||
| This or That / How Well Do You Know Me | Included | Included |
|
| Recent answer history (last 30 days) | ✅ | ✅ |
|
||||||
| Recent answer history | Included | Included |
|
| Full answer history (search, filter, export) | — | ✅ |
|
||||||
| Memory Lane capsules | Limited | Full access |
|
| Saved spin-wheel sessions | — | ✅ |
|
||||||
| Desire Sync | Limited | Full access |
|
| Memory Lane (locked time capsules) | — | ✅ |
|
||||||
| Connection Challenges | Free + mixed access | Full access |
|
| Desire Sync (preferences alignment exercise) | — | ✅ |
|
||||||
| Date ideas, matches, and bucket list | Included | Premium ideas gated |
|
| Select Connection Challenges (multi-day programs) | ✅ (free) + 🎟️ (premium tiers) | ✅ |
|
||||||
| Push reminders + quiet hours | Included | Included |
|
| Push reminders with quiet-hour support | ✅ | ✅ |
|
||||||
| Account deletion | Included | Included |
|
| Account deletion + data export | ✅ | ✅ |
|
||||||
|
|
||||||
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
|
One subscription unlocks premium for **both** partners — `couples/{coupleId}/entitlements` is per-couple, not per-user.
|
||||||
flow exists.
|
|
||||||
|
---
|
||||||
|
|
||||||
## Platform status
|
## Platform status
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Stack | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Android | Reference implementation | Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore, Firebase, Tink |
|
| **Android** | Kotlin · Jetpack Compose · Material 3 · Hilt · Room · DataStore | 🟢 **Reference implementation** | Feature-complete MVP, light/dark theme polished |
|
||||||
| iOS | Scaffold in progress | SwiftUI screen parity exists; pairing is blocked until E2EE interop is complete |
|
| **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 | Shared source of truth | Firebase Auth, Firestore, Cloud Functions, FCM, App Check |
|
| **Backend** | Firebase Auth · Firestore · Cloud Functions · FCM · App Check | 🟢 **Shared source of truth** | 17 callable/trigger/scheduled/webhook functions |
|
||||||
| Billing | Server verified | RevenueCat webhook writes Firestore entitlements observed by the app |
|
| **Billing** | RevenueCat · Google Play Billing · StoreKit | 🟢 **Server-verified** | Webhook → Firestore entitlements → `CouplePremiumChecker` |
|
||||||
|
|
||||||
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
|
> 📐 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.
|
||||||
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
|
|
||||||
|
|
||||||
## Architecture
|
---
|
||||||
|
|
||||||
|
## Architecture at a glance
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android (Kotlin/Compose) iOS (SwiftUI)
|
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
Hilt · Room · DataStore MVVM · AppState · SPM
|
│ Android (Kotlin/Compose)│ │ iOS (SwiftUI) │
|
||||||
Tink AEAD · Argon2id CryptoKit interop in progress
|
│ • Hilt DI · Room · DSK │ │ • MVVM · AppState · SPM │
|
||||||
\ /
|
│ • Tink AEAD + Argon2id │ │ • CryptoKit (follow-up) │
|
||||||
\ /
|
└────────────┬─────────────┘ └────────────┬─────────────┘
|
||||||
v v
|
│ │
|
||||||
Firebase + RevenueCat
|
└──────────────┬─────────────────────┘
|
||||||
Auth · Firestore · Functions · FCM
|
│
|
||||||
App Check · server-verified billing
|
┌──────────▼──────────┐
|
||||||
|
│ Firebase │
|
||||||
|
│ • Auth (email / │
|
||||||
|
│ Google via │
|
||||||
|
│ Credential Mgr) │
|
||||||
|
│ • Firestore │
|
||||||
|
│ (couple-scoped) │
|
||||||
|
│ • Cloud Functions │
|
||||||
|
│ • FCM │
|
||||||
|
│ • App Check │
|
||||||
|
│ (Play Integrity)│
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌──────────▼──────────┐
|
||||||
|
│ RevenueCat │
|
||||||
|
│ → webhook │
|
||||||
|
│ → Firestore │
|
||||||
|
│ entitlements │
|
||||||
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Core rules:
|
- **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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Security and privacy
|
## Tech stack
|
||||||
|
|
||||||
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
|
### Android
|
||||||
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.
|
|
||||||
|
|
||||||
The canonical technical reference is
|
| Layer | Stack |
|
||||||
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
|
| --- | --- |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
## Local development
|
### iOS
|
||||||
|
|
||||||
<details open>
|
| Layer | Stack |
|
||||||
<summary><strong>Prerequisites</strong></summary>
|
| --- | --- |
|
||||||
|
| 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+ |
|
||||||
|
|
||||||
- Android Studio, Android SDK, and JDK 17
|
### Backend (shared)
|
||||||
- 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
|
|
||||||
|
|
||||||
For iOS work:
|
| 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 |
|
||||||
|
|
||||||
- macOS, Xcode 16, XcodeGen, and iOS 17+
|
> 🚫 **No anonymous auth.** There is no anonymous sign-in or account-linking flow in either platform. Accounts are email/password or Google.
|
||||||
- `iphone/Closer/GoogleService-Info.plist`
|
|
||||||
- RevenueCat iOS API key
|
|
||||||
|
|
||||||
</details>
|
---
|
||||||
|
|
||||||
<details>
|
## Repository layout
|
||||||
<summary><strong>Android</strong></summary>
|
|
||||||
|
```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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Android
|
||||||
cp local.properties.example local.properties
|
cp local.properties.example local.properties
|
||||||
|
# iOS
|
||||||
|
cp iphone/Closer/GoogleService-Info.plist.example iphone/Closer/GoogleService-Info.plist
|
||||||
```
|
```
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
|
|
@ -186,16 +272,19 @@ RC_API_KEY_ANDROID=your_revenuecat_android_key
|
||||||
RC_API_KEY_IOS=your_revenuecat_ios_key
|
RC_API_KEY_IOS=your_revenuecat_ios_key
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :app:assembleDebug
|
./gradlew :app:assembleDebug
|
||||||
./gradlew :app:installDebug
|
./gradlew :app:installDebug
|
||||||
./gradlew :app:testDebugUnitTest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
```bash
|
||||||
|
./gradlew :app:compileDebugKotlin # fast verification
|
||||||
|
./gradlew :app:testDebugUnitTest # 205 unit tests
|
||||||
|
```
|
||||||
|
|
||||||
<details>
|
### iOS
|
||||||
<summary><strong>iOS</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd iphone
|
cd iphone
|
||||||
|
|
@ -205,86 +294,82 @@ xed Closer.xcodeproj
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodebuild -project iphone/Closer.xcodeproj \
|
xcodebuild -project iphone/Closer.xcodeproj \
|
||||||
-scheme Closer \
|
-scheme Closer \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||||
build
|
build
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
### Firebase Functions
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Firebase Functions</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd functions
|
cd functions
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
```bash
|
||||||
|
npm test # 24 functions tests
|
||||||
## 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
|
## Roadmap
|
||||||
|
|
||||||
In progress:
|
In progress:
|
||||||
|
|
||||||
- iOS E2EE interoperability with Android's Tink key material
|
- 🔐 **iOS E2EE parity** (CryptoKit interop with Android's Tink key material) — *unblocks pairing from iOS*.
|
||||||
- More on-device/instrumented smoke coverage
|
- 🧪 **On-device / instrumented test coverage** (Compose UI / Espresso smoke) — currently `app/src/test` only.
|
||||||
- Activity `uiMode` sync for in-app theme and dark artwork
|
- 🎨 **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`.
|
||||||
- Internal-testing release configuration, legal/support URLs, and RevenueCat offering validation
|
- 🛒 **Real release config** — version, legal/support URLs, RevenueCat offerings verified end-to-end on internal testing.
|
||||||
|
|
||||||
Out of scope for now:
|
Out of scope (for now):
|
||||||
|
|
||||||
- AI-generated core questions
|
- AI-assisted question suggestions
|
||||||
- Group relationship spaces beyond dyadic couples
|
- Native group/relationship types beyond dyadic couples
|
||||||
- Wear OS / watchOS companions
|
- Wearable (Wear OS / watchOS) companions
|
||||||
- Live video or voice sessions
|
- Live video or voice sessions
|
||||||
|
|
||||||
## Project docs
|
See `Future.md` for the full backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project history & docs
|
||||||
|
|
||||||
| Doc | Purpose |
|
| Doc | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
|
| [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, known landmines |
|
||||||
| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes |
|
| [`docs/release/`](docs/release/) | Release prep + store assets |
|
||||||
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
|
| [`docs/qa/`](docs/qa/) | QA playbook + private-MVP checklist |
|
||||||
| [docs/release/](docs/release/) | Release prep and store assets |
|
| `Future.md` | Backlog + roadmap |
|
||||||
| [docs/qa/](docs/qa/) | Manual QA checklist |
|
| `HISTORY.md` | Changelog + release notes |
|
||||||
| [Future.md](Future.md) | Backlog and roadmap |
|
| `PROJECT.md` | Scope, feature matrix, architectural decisions |
|
||||||
| [HISTORY.md](HISTORY.md) | Changelog and release notes |
|
|
||||||
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.core.billing
|
||||||
|
|
||||||
import app.closer.data.remote.FirestoreCollections
|
import app.closer.data.remote.FirestoreCollections
|
||||||
import app.closer.domain.repository.CoupleRepository
|
import app.closer.domain.repository.CoupleRepository
|
||||||
import app.closer.domain.repository.AuthRepository
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -29,12 +29,11 @@ import javax.inject.Singleton
|
||||||
class CouplePremiumChecker @Inject constructor(
|
class CouplePremiumChecker @Inject constructor(
|
||||||
private val entitlementChecker: EntitlementChecker,
|
private val entitlementChecker: EntitlementChecker,
|
||||||
private val firestore: FirebaseFirestore,
|
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. */
|
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
|
||||||
fun isPremium(): Flow<Boolean> = flow {
|
fun isPremium(): Flow<Boolean> = flow {
|
||||||
val uid = authRepository.currentUserId
|
val uid = FirebaseAuth.getInstance().currentUser?.uid
|
||||||
val partnerId = uid?.let {
|
val partnerId = uid?.let {
|
||||||
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
|
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,9 @@ fun AppNavigation(
|
||||||
onNavigate = navigateRoute
|
onNavigate = navigateRoute
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.WHEEL_HISTORY) {
|
||||||
|
GameHistoryScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
composable(route = AppRoute.GAME_HISTORY) {
|
composable(route = AppRoute.GAME_HISTORY) {
|
||||||
GameHistoryScreen(onNavigate = navigateRoute)
|
GameHistoryScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
|
@ -601,14 +604,11 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.ACTIVITY) {
|
composable(route = AppRoute.ACTIVITY) {
|
||||||
ActivityScreen(onNavigate = navigateRoute)
|
ActivityScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
// Dev-only art/layout previews: keep them out of the release nav graph.
|
composable(route = AppRoute.ART_PREVIEW) {
|
||||||
if (app.closer.BuildConfig.DEBUG) {
|
app.closer.ui.debug.ArtPreviewScreen(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)
|
||||||
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
|
|
||||||
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ object AppRoute {
|
||||||
const val DELETE_ACCOUNT = "delete_account"
|
const val DELETE_ACCOUNT = "delete_account"
|
||||||
const val SECURITY = "security_settings"
|
const val SECURITY = "security_settings"
|
||||||
const val CHANGE_PASSWORD = "change_password"
|
const val CHANGE_PASSWORD = "change_password"
|
||||||
|
const val WHEEL_HISTORY = "wheel_history"
|
||||||
const val GAME_HISTORY = "game_history"
|
const val GAME_HISTORY = "game_history"
|
||||||
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
|
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
|
||||||
const val DESIRE_SYNC_REPLAY = "desire_sync_replay/{sessionId}"
|
const val DESIRE_SYNC_REPLAY = "desire_sync_replay/{sessionId}"
|
||||||
|
|
@ -121,6 +122,7 @@ object AppRoute {
|
||||||
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
||||||
Definition(SECURITY, "Security", "settings"),
|
Definition(SECURITY, "Security", "settings"),
|
||||||
Definition(CHANGE_PASSWORD, "Change Password", "settings"),
|
Definition(CHANGE_PASSWORD, "Change Password", "settings"),
|
||||||
|
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
|
||||||
Definition(GAME_HISTORY, "Past Games", "play"),
|
Definition(GAME_HISTORY, "Past Games", "play"),
|
||||||
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
|
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
|
||||||
Definition(DESIRE_SYNC_REPLAY, "Desire Sync Results", "play"),
|
Definition(DESIRE_SYNC_REPLAY, "Desire Sync Results", "play"),
|
||||||
|
|
@ -176,6 +178,7 @@ object AppRoute {
|
||||||
SPIN_WHEEL_RANDOM,
|
SPIN_WHEEL_RANDOM,
|
||||||
WHEEL_SESSION,
|
WHEEL_SESSION,
|
||||||
WHEEL_COMPLETE,
|
WHEEL_COMPLETE,
|
||||||
|
WHEEL_HISTORY,
|
||||||
GAME_HISTORY,
|
GAME_HISTORY,
|
||||||
THIS_OR_THAT_REPLAY,
|
THIS_OR_THAT_REPLAY,
|
||||||
DESIRE_SYNC_REPLAY,
|
DESIRE_SYNC_REPLAY,
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,7 @@ interface QuestionDao {
|
||||||
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
|
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
|
||||||
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
|
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
|
||||||
|
|
||||||
// Excludes daily_fun_mc: those are throwaway novelty MCs ("pick a laugh-track move") that
|
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active' AND TRIM(text) <> ''")
|
||||||
// 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>
|
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")
|
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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,11 +62,7 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
// Close on error so collectors see it and can surface a retryable ERROR state —
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
// 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)
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,7 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
// Close on error so collectors see it and can surface a retryable ERROR state —
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
// 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)
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
// Close on error so collectors see it and can surface a retryable ERROR state —
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
// 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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val raw = snap.get("answers") as? Map<String, *>
|
val raw = snap.get("answers") as? Map<String, *>
|
||||||
val byUser = parseAnswers(raw, coupleId)
|
val byUser = parseAnswers(raw, coupleId)
|
||||||
|
|
|
||||||
|
|
@ -228,14 +228,7 @@ class QuestionSessionRepositoryImpl @Inject constructor(
|
||||||
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
|
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
|
||||||
isPremium = doc.getBoolean("isPremium") ?: false,
|
isPremium = doc.getBoolean("isPremium") ?: false,
|
||||||
status = doc.getString("status") ?: "active",
|
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) }
|
.onFailure { crashReporter.recordException(it) }
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,7 @@ object ChallengeStateMachine {
|
||||||
cta = cta,
|
cta = cta,
|
||||||
badge = null,
|
badge = null,
|
||||||
canAdvance = canAdvance,
|
canAdvance = canAdvance,
|
||||||
missedDate = missedDate,
|
missedDate = missedDate
|
||||||
statusDay = statusDay
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,17 @@
|
||||||
package app.closer.domain
|
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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up for
|
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
|
||||||
* the 18+ gate but PERSISTED at profile creation: a Firestore write issued immediately after
|
* 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).
|
* `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]
|
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
|
||||||
* writes it once the session has settled — and skips re-asking.
|
* 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).
|
||||||
* 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
|
@Singleton
|
||||||
class SignupHandoff @Inject constructor(
|
class SignupHandoff @Inject constructor() {
|
||||||
private val dataStore: DataStore<Preferences>
|
var pendingBirthDate: Long? = null
|
||||||
) {
|
|
||||||
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,8 +25,6 @@ import java.time.LocalDate
|
||||||
* @property badge small celebratory indicator shown when the challenge is complete, or null
|
* @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 canAdvance true when the user can mark today's step complete
|
||||||
* @property missedDate the local date of a detected missed day, if any
|
* @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(
|
data class ChallengeState(
|
||||||
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
|
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
|
||||||
|
|
@ -42,8 +40,7 @@ data class ChallengeState(
|
||||||
val cta: String? = null,
|
val cta: String? = null,
|
||||||
val badge: String? = null,
|
val badge: String? = null,
|
||||||
val canAdvance: Boolean = false,
|
val canAdvance: Boolean = false,
|
||||||
val missedDate: LocalDate? = null,
|
val missedDate: LocalDate? = null
|
||||||
val statusDay: Int = 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,5 @@ data class QuestionSession(
|
||||||
val completedByUsers: List<String> = emptyList(),
|
val completedByUsers: List<String> = emptyList(),
|
||||||
/** Members other than the starter who have opened/joined this active session. Drives the
|
/** Members other than the starter who have opened/joined this active session. Drives the
|
||||||
* `partner_joined_game` notification to the starter. */
|
* `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,16 +61,9 @@ class DailyQuestionResolver @Inject constructor(
|
||||||
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.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) {
|
val question = if (assignment != null) {
|
||||||
questionRepository.getQuestionById(assignment.questionId)
|
questionRepository.getQuestionById(assignment.questionId)
|
||||||
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
|
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||||
?: questionRepository.getDailyQuestion()
|
?: questionRepository.getDailyQuestion()
|
||||||
} else {
|
} else {
|
||||||
// No assignment yet — request one, but stay usable with the deterministic local
|
// No assignment yet — request one, but stay usable with the deterministic local
|
||||||
|
|
@ -81,7 +74,7 @@ class DailyQuestionResolver @Inject constructor(
|
||||||
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
||||||
)
|
)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
|
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||||
?: questionRepository.getDailyQuestion()
|
?: questionRepository.getDailyQuestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,13 @@ import javax.inject.Singleton
|
||||||
* auto-displays the push and this monitor isn't consulted — which is the desired behaviour.
|
* auto-displays the push and this monitor isn't consulted — which is the desired behaviour.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ActiveGameSessionMonitor @Inject constructor(
|
class ActiveGameSessionMonitor @Inject constructor() {
|
||||||
private val gamePromptController: GamePromptController
|
|
||||||
) {
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var activeSessionId: String? = null
|
var activeSessionId: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun enter(sessionId: String) {
|
fun enter(sessionId: String) {
|
||||||
if (sessionId.isNotBlank()) activeSessionId = sessionId
|
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) {
|
fun leave(sessionId: String) {
|
||||||
|
|
|
||||||
|
|
@ -47,27 +47,13 @@ class GamePromptController @Inject constructor() {
|
||||||
) {
|
) {
|
||||||
if (coupleId.isBlank() || gameType.isBlank()) return
|
if (coupleId.isBlank() || gameType.isBlank()) return
|
||||||
// Don't let a transient presence nudge (started/joined) clobber a persistent banner
|
// Don't let a transient presence nudge (started/joined) clobber a persistent banner
|
||||||
// (your turn / results) the user hasn't acted on yet — unless the nudge is about a
|
// (your turn / results) the user hasn't acted on yet.
|
||||||
// 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
|
val current = _prompt.value
|
||||||
val sameSession = current?.gameSessionId != null && current.gameSessionId == gameSessionId
|
if (current != null && current.kind.persistent && !kind.persistent) return
|
||||||
if (current != null && current.kind.persistent && !kind.persistent && sameSession) return
|
|
||||||
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
|
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
_prompt.value = null
|
_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,19 +952,12 @@ 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 {
|
fun LocalAnswer.revealSummary(): String {
|
||||||
return when (answerType) {
|
return when (answerType) {
|
||||||
"written" -> writtenText.orEmpty()
|
"written" -> writtenText.orEmpty()
|
||||||
"scale" -> "You chose ${scaleValue ?: "-"}."
|
"scale" -> "You chose ${scaleValue ?: "-"}."
|
||||||
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||||
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
|
.ifEmpty { selectedOptionIds }
|
||||||
.joinToString()
|
.joinToString()
|
||||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||||
}
|
}
|
||||||
|
|
@ -985,7 +978,7 @@ private fun LocalAnswer.partnerRevealSummary(): String {
|
||||||
"written" -> writtenText.orEmpty()
|
"written" -> writtenText.orEmpty()
|
||||||
"scale" -> "They chose ${scaleValue ?: "-"}."
|
"scale" -> "They chose ${scaleValue ?: "-"}."
|
||||||
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||||
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
|
.ifEmpty { selectedOptionIds }
|
||||||
.joinToString()
|
.joinToString()
|
||||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -202,17 +201,22 @@ fun SignUpScreen(
|
||||||
val dobLabel = state.birthDate?.let {
|
val dobLabel = state.birthDate?.let {
|
||||||
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
|
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 = {
|
val openDobPicker = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
showDobPicker = true
|
val initMillis = state.birthDate
|
||||||
}
|
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||||
if (showDobPicker) {
|
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||||
app.closer.ui.components.DobPickerDialog(
|
android.app.DatePickerDialog(
|
||||||
initialSelectedDateMillis = state.birthDate,
|
context,
|
||||||
onConfirm = viewModel::updateBirthDate,
|
{ _, y, m, d ->
|
||||||
onDismiss = { showDobPicker = false }
|
viewModel.updateBirthDate(
|
||||||
)
|
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
c.get(java.util.Calendar.YEAR),
|
||||||
|
c.get(java.util.Calendar.MONTH),
|
||||||
|
c.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
||||||
}
|
}
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,7 @@ class SignUpViewModel @Inject constructor(
|
||||||
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
// Firestore write issued right after signUpWithEmail races the auth-token attachment
|
||||||
// and silently fails rules. CreateProfile writes it once the session has settled and
|
// 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.)
|
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
|
||||||
authRepository.currentUserId?.let { uid ->
|
signupHandoff.pendingBirthDate = dob
|
||||||
signupHandoff.setPendingBirthDate(uid, dob)
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
|
|
|
||||||
|
|
@ -445,10 +445,7 @@ private fun ChallengePickCard(
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// Hide the 🔒 Premium badge once the couple has premium access — locked
|
if (challenge.isPremium) {
|
||||||
// hints on owned features read as broken entitlements (A-003 pattern,
|
|
||||||
// matches the Play hub's showPremiumBadge = !hasPremium).
|
|
||||||
if (challenge.isPremium && !hasPremium) {
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = CloserPalette.Gold.copy(alpha = 0.15f)
|
color = CloserPalette.Gold.copy(alpha = 0.15f)
|
||||||
|
|
@ -530,10 +527,6 @@ private fun ChallengesActiveScreen(
|
||||||
val stateCopy = cs?.copy ?: ""
|
val stateCopy = cs?.copy ?: ""
|
||||||
val ctaLabel: String? = cs?.cta
|
val ctaLabel: String? = cs?.cta
|
||||||
val missedDay = cs?.missedDate
|
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.
|
// Route each CTA to its correct action.
|
||||||
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
|
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
|
||||||
|
|
@ -661,14 +654,12 @@ private fun ChallengesActiveScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = if (dayUnlocked) dayPrompt.prompt
|
text = dayPrompt.prompt,
|
||||||
else "Day $currentDay unlocks tomorrow \uD83C\uDF19",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
||||||
color = if (dayUnlocked) MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
|
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
|
||||||
)
|
)
|
||||||
if (dayUnlocked && dayPrompt.hint.isNotBlank()) {
|
if (dayPrompt.hint.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = dayPrompt.hint,
|
text = dayPrompt.hint,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.closer.ui.brand.CloserBrandCopy
|
import app.closer.ui.brand.CloserBrandCopy
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -80,10 +79,7 @@ fun BrandMessageRotator(
|
||||||
style = style,
|
style = style,
|
||||||
color = color,
|
color = color,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
// The primary privacy message wraps to 4 lines at default font scale on the
|
maxLines = 3
|
||||||
// welcome/splash width; 3 clipped it mid-sentence with no ellipsis (AUTH-TRUNC-001).
|
|
||||||
maxLines = 4,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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,24 +149,10 @@ private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
|
||||||
return when (prompt.kind) {
|
return when (prompt.kind) {
|
||||||
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
|
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
|
||||||
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
|
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
|
||||||
GamePromptKind.YOUR_TURN -> PromptStyle(
|
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play")
|
||||||
"$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(
|
GamePromptKind.RESULTS -> PromptStyle(
|
||||||
"You both finished",
|
"You both finished",
|
||||||
when (prompt.gameType) {
|
if (resolved != null) "See how you and $resolved compare" else "See how you both compare",
|
||||||
app.closer.domain.model.GameType.HOW_WELL ->
|
|
||||||
if (resolved != null) "See how well you know $resolved" else "See how well you know each other"
|
|
||||||
app.closer.domain.model.GameType.DESIRE_SYNC -> "See what you both said yes to"
|
|
||||||
else -> if (resolved != null) "See how you and $resolved compare" else "See how you both compare"
|
|
||||||
},
|
|
||||||
"View"
|
"View"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,7 @@ private fun DateCard(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(32.dp),
|
shape = RoundedCornerShape(32.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
// Top card must be fully opaque or the next card's text bleeds through it.
|
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 0.96f)
|
||||||
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 1f)
|
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -586,7 +585,7 @@ private fun MatchOverlay(
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "It’s a match!",
|
text = "It is a match!",
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
data class DateMatchUiState(
|
data class DateMatchUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
|
|
@ -40,8 +39,6 @@ data class DateMatchUiState(
|
||||||
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
|
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
|
||||||
val matches: List<DateMatch> = emptyList(),
|
val matches: List<DateMatch> = emptyList(),
|
||||||
val currentIndex: Int = 0,
|
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 justMatched: DateMatch? = null,
|
||||||
val hasPremium: Boolean = false
|
val hasPremium: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
@ -90,11 +87,7 @@ class DateMatchViewModel @Inject constructor(
|
||||||
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
// Deterministic per-couple order: kills the "every couple's first card is the
|
val ideas = repository.getDateIdeas()
|
||||||
// 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(
|
_uiState.value = DateMatchUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
coupleId = couple?.id,
|
coupleId = couple?.id,
|
||||||
|
|
@ -131,20 +124,9 @@ class DateMatchViewModel @Inject constructor(
|
||||||
matches.firstOrNull { it.id !in known }
|
matches.firstOrNull { it.id !in known }
|
||||||
}
|
}
|
||||||
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
|
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
|
||||||
val swipeMap = swipes.associate { it.dateIdeaId to it.action }
|
|
||||||
_uiState.update { state ->
|
_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(
|
state.copy(
|
||||||
dateIdeas = deck,
|
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
|
||||||
currentIndex = index,
|
|
||||||
swipeFilterApplied = applied,
|
|
||||||
ownSwipes = swipeMap,
|
|
||||||
matches = matches,
|
matches = matches,
|
||||||
justMatched = newMatch ?: state.justMatched
|
justMatched = newMatch ?: state.justMatched
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
@ -103,7 +102,6 @@ data class DesireSyncUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
val syncFailed: Boolean = false,
|
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -124,8 +122,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val dataSource: FirestoreDesireSyncDataSource,
|
private val dataSource: FirestoreDesireSyncDataSource,
|
||||||
private val premiumChecker: CouplePremiumChecker,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
||||||
|
|
@ -197,19 +194,8 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
|
|
||||||
/** First partner: pick the neutral question set and open the shared session. */
|
/** First partner: pick the neutral question set and open the shared session. */
|
||||||
private suspend fun createSession(uid: String) {
|
private suspend fun createSession(uid: String) {
|
||||||
val count = _uiState.value.selectedLength.count
|
val questions = loadNeutralQuestions().shuffled().take(_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 (questions.isEmpty()) return fail("No questions available.")
|
||||||
if (cId != null) runCatching { seenQuestionStore.markSeen(cId, questions.map { it.id }) }
|
|
||||||
|
|
||||||
val startResult = runCatching {
|
val startResult = runCatching {
|
||||||
gameSessionManager.startGame(
|
gameSessionManager.startGame(
|
||||||
|
|
@ -315,18 +301,6 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
// The observer flips WAITING → REVEAL once the partner's answers land.
|
// 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() {
|
fun retrySubmit() {
|
||||||
val answers = _uiState.value.myAnswers
|
val answers = _uiState.value.myAnswers
|
||||||
if (answers.isEmpty()) return
|
if (answers.isEmpty()) return
|
||||||
|
|
@ -340,14 +314,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
observeJob = viewModelScope.launch {
|
||||||
dataSource.observeAnswers(cId, sId)
|
dataSource.observeAnswers(cId, sId).collect { answers ->
|
||||||
.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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
when {
|
||||||
|
|
@ -460,7 +427,7 @@ fun DesireSyncScreen(
|
||||||
DesireSyncPhase.ERROR -> DSErrorScreen(
|
DesireSyncPhase.ERROR -> DSErrorScreen(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
onBack = viewModel::quit,
|
||||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
|
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||||
)
|
)
|
||||||
DesireSyncPhase.SETUP -> DSSetupScreen(
|
DesireSyncPhase.SETUP -> DSSetupScreen(
|
||||||
selectedLength = state.selectedLength,
|
selectedLength = state.selectedLength,
|
||||||
|
|
@ -493,9 +460,7 @@ fun DesireSyncScreen(
|
||||||
total = state.questions.size,
|
total = state.questions.size,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onHome = viewModel::quit,
|
onHome = viewModel::quit
|
||||||
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
|
||||||
showNextBeat = true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -807,9 +772,7 @@ private fun DSRevealScreen(
|
||||||
total: Int,
|
total: Int,
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit
|
||||||
onOpenDaily: () -> Unit = {},
|
|
||||||
showNextBeat: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -897,9 +860,6 @@ private fun DSRevealScreen(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
) { Text("Back to Play") }
|
) { Text("Back to Play") }
|
||||||
if (showNextBeat) {
|
|
||||||
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,4 @@ package app.closer.ui.games
|
||||||
object GameCopy {
|
object GameCopy {
|
||||||
/** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */
|
/** 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."
|
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."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
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,11 +1,5 @@
|
||||||
package app.closer.ui.games
|
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 app.closer.ui.theme.closerBackgroundBrush
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -22,7 +16,6 @@ import androidx.compose.material3.Button
|
||||||
import app.closer.ui.components.CloserHeartLoader
|
import app.closer.ui.components.CloserHeartLoader
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -66,17 +59,12 @@ data class WaitingForPartnerUiState(
|
||||||
* stuck on this "waiting" screen. Every current game is async (both play on their own device),
|
* 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.
|
* 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
|
@HiltViewModel
|
||||||
class WaitingForPartnerViewModel @Inject constructor(
|
class WaitingForPartnerViewModel @Inject constructor(
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager
|
||||||
private val functions: FirebaseFunctions
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
|
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
|
||||||
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
|
||||||
|
|
@ -106,11 +94,7 @@ class WaitingForPartnerViewModel @Inject constructor(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
gameType = session.gameType,
|
gameType = session.gameType,
|
||||||
partnerName = partnerName,
|
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,32 +102,6 @@ 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. */
|
/** Force-end the partner's active session so this user can start a new game. */
|
||||||
fun abandonPartnerGame() {
|
fun abandonPartnerGame() {
|
||||||
val cId = coupleId ?: return
|
val cId = coupleId ?: return
|
||||||
|
|
@ -174,14 +132,6 @@ fun WaitingForPartnerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
LaunchedEffect(state.nudgeResult) {
|
|
||||||
state.nudgeResult?.let {
|
|
||||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
|
||||||
viewModel.consumeNudgeResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -226,21 +176,14 @@ fun WaitingForPartnerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
// "Waiting for X" is wrong once X already played — then it's THIS
|
text = "Waiting for ${state.partnerName}",
|
||||||
// user's move and the screen should say so.
|
|
||||||
text = if (state.partnerFinished) "Your turn"
|
|
||||||
else "Waiting for ${state.partnerName}",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = if (state.partnerFinished) {
|
text = "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game.",
|
||||||
"${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,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
|
@ -277,15 +220,6 @@ fun WaitingForPartnerScreen(
|
||||||
Text("Back to Games")
|
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) {
|
TextButton(onClick = viewModel::abandonPartnerGame) {
|
||||||
Text(
|
Text(
|
||||||
"End their game",
|
"End their game",
|
||||||
|
|
|
||||||
|
|
@ -1015,8 +1015,6 @@ class HomeViewModel @Inject constructor(
|
||||||
private fun String.toHomeLabel(): String =
|
private fun String.toHomeLabel(): String =
|
||||||
split("_", "-")
|
split("_", "-")
|
||||||
.filter { part -> part.isNotBlank() }
|
.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() } }
|
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.ui.draw.clip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
|
@ -87,7 +85,6 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
@ -152,7 +149,6 @@ data class HowWellUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
val syncFailed: Boolean = false,
|
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -337,18 +333,6 @@ class HowWellViewModel @Inject constructor(
|
||||||
// The observer flips WAITING → COMPLETE once the partner's answers land.
|
// 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() {
|
fun retrySubmit() {
|
||||||
if (myAnswers.isEmpty()) return
|
if (myAnswers.isEmpty()) return
|
||||||
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
|
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
|
||||||
|
|
@ -361,14 +345,7 @@ class HowWellViewModel @Inject constructor(
|
||||||
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
observeJob = viewModelScope.launch {
|
||||||
dataSource.observeAnswers(cId, sId)
|
dataSource.observeAnswers(cId, sId).collect { answers ->
|
||||||
.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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
when {
|
||||||
|
|
@ -450,23 +427,6 @@ class HowWellViewModel @Inject constructor(
|
||||||
load()
|
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() {
|
fun onNavigated() {
|
||||||
_uiState.update { it.copy(navigateTo = null) }
|
_uiState.update { it.copy(navigateTo = null) }
|
||||||
}
|
}
|
||||||
|
|
@ -508,7 +468,7 @@ fun HowWellScreen(
|
||||||
HowWellPhase.ERROR -> HowWellErrorScreen(
|
HowWellPhase.ERROR -> HowWellErrorScreen(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
onBack = viewModel::quit,
|
||||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
|
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||||
)
|
)
|
||||||
HowWellPhase.SETUP -> HWSetupScreen(
|
HowWellPhase.SETUP -> HWSetupScreen(
|
||||||
selectedLength = state.selectedLength,
|
selectedLength = state.selectedLength,
|
||||||
|
|
@ -551,10 +511,7 @@ fun HowWellScreen(
|
||||||
amSubject = state.amSubject,
|
amSubject = state.amSubject,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onSwapRoles = viewModel::startSwapped,
|
onHome = viewModel::quit
|
||||||
onHome = viewModel::quit,
|
|
||||||
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
|
||||||
showNextBeat = true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -899,10 +856,7 @@ private fun CompleteScreen(
|
||||||
amSubject: Boolean,
|
amSubject: Boolean,
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onSwapRoles: () -> Unit,
|
onHome: () -> Unit
|
||||||
onHome: () -> Unit,
|
|
||||||
onOpenDaily: () -> Unit = {},
|
|
||||||
showNextBeat: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -956,36 +910,17 @@ private fun CompleteScreen(
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
// The guesser just tried to read their partner — offer the natural next beat:
|
Button(
|
||||||
// swap so the partner now guesses THEM (Newlywed-style role reversal).
|
onClick = onPlayAgain,
|
||||||
if (!amSubject) {
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
Button(
|
shape = RoundedCornerShape(18.dp),
|
||||||
onClick = onSwapRoles,
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
) { Text("Play again", color = Color.White) }
|
||||||
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(
|
OutlinedButton(
|
||||||
onClick = onHome,
|
onClick = onHome,
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
) { Text("Back to Play") }
|
) { Text("Back to Play") }
|
||||||
if (showNextBeat) {
|
|
||||||
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1020,9 +955,7 @@ private fun HowWellScoreRing(
|
||||||
) {
|
) {
|
||||||
val progress = if (total == 0) 0f else score.toFloat() / total
|
val progress = if (total == 0) 0f else score.toFloat() / total
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.semantics(mergeDescendants = true) {
|
modifier = modifier,
|
||||||
contentDescription = "You guessed $score of $total correctly"
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
@ -1060,19 +993,14 @@ private fun HowWellScoreRing(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BreakdownRow(result: HowWellResult) {
|
private fun BreakdownRow(result: HowWellResult) {
|
||||||
// Match-row colors come as a container+content PAIR per theme: the old fixed pale-green
|
val matchColor = Color(0xFF2E7D32)
|
||||||
// container kept theme-driven onSurfaceVariant text, which is near-invisible in dark mode.
|
val closeColor = Color(0xFFF57F17)
|
||||||
val dark = androidx.compose.foundation.isSystemInDarkTheme()
|
val missColor = Color(0xFFC62828)
|
||||||
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (result.isMatch) matchContainer else MaterialTheme.colorScheme.surface
|
containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(2.dp)
|
elevation = CardDefaults.cardElevation(2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1090,7 +1018,7 @@ private fun BreakdownRow(result: HowWellResult) {
|
||||||
Text(
|
Text(
|
||||||
text = result.question.text,
|
text = result.question.text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = questionColor,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
@ -1359,7 +1287,6 @@ fun HowWellReplayScreen(
|
||||||
amSubject = phase.amSubject,
|
amSubject = phase.amSubject,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
|
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
|
||||||
onSwapRoles = { onNavigate(AppRoute.HOW_WELL) },
|
|
||||||
onHome = { onNavigate(AppRoute.PLAY) }
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -174,17 +173,22 @@ fun CreateProfileScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
|
||||||
val openDobPicker = {
|
val openDobPicker = {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
showDobPicker = true
|
val initMillis = state.birthDate
|
||||||
}
|
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
|
||||||
if (showDobPicker) {
|
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
|
||||||
app.closer.ui.components.DobPickerDialog(
|
android.app.DatePickerDialog(
|
||||||
initialSelectedDateMillis = state.birthDate,
|
context,
|
||||||
onConfirm = viewModel::selectBirthDate,
|
{ _, y, m, d ->
|
||||||
onDismiss = { showDobPicker = false }
|
viewModel.selectBirthDate(
|
||||||
)
|
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
c.get(java.util.Calendar.YEAR),
|
||||||
|
c.get(java.util.Calendar.MONTH),
|
||||||
|
c.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.currentStep) {
|
when (state.currentStep) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
// 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
|
// 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.
|
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
|
||||||
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate(uid)
|
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
|
||||||
val needsDob = effectiveDob == null
|
val needsDob = effectiveDob == null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -189,7 +189,7 @@ class CreateProfileViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
|
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
|
||||||
signupHandoff.clear(uid)
|
signupHandoff.pendingBirthDate = null
|
||||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
||||||
|
|
|
||||||
|
|
@ -337,8 +337,6 @@ private fun answerSummary(question: Question, state: LocalQuestionUiState): Stri
|
||||||
fun String.displayCategoryName(): String {
|
fun String.displayCategoryName(): String {
|
||||||
return split("_", "-")
|
return split("_", "-")
|
||||||
.filter { it.isNotBlank() }
|
.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() } }
|
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,44 @@ import app.closer.ui.settings.SettingsPrimaryDeep
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
// The routed EditProfileScreen wrapper was removed as dead code (no nav route references
|
@Composable
|
||||||
// it); AccountScreen embeds EditProfileContent directly.
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditProfileContent(
|
fun EditProfileContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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,8 +51,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import app.closer.ui.components.CelebrationOverlay
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -98,7 +96,6 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -153,7 +150,6 @@ data class ThisOrThatUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
val syncFailed: Boolean = false,
|
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -306,14 +302,7 @@ class ThisOrThatViewModel @Inject constructor(
|
||||||
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
observeJob = viewModelScope.launch {
|
||||||
dataSource.observeAnswers(cId, sId)
|
dataSource.observeAnswers(cId, sId).collect { answers ->
|
||||||
.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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
when {
|
||||||
|
|
@ -432,18 +421,6 @@ 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() {
|
fun retrySubmit() {
|
||||||
val answers = _uiState.value.myAnswers
|
val answers = _uiState.value.myAnswers
|
||||||
if (answers.isEmpty()) return
|
if (answers.isEmpty()) return
|
||||||
|
|
@ -501,7 +478,7 @@ fun ThisOrThatScreen(
|
||||||
TotPhase.ERROR -> ErrorState(
|
TotPhase.ERROR -> ErrorState(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
onBack = viewModel::quit,
|
||||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
|
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||||
)
|
)
|
||||||
TotPhase.WAITING -> WaitingForRevealScreen(
|
TotPhase.WAITING -> WaitingForRevealScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
|
|
@ -520,9 +497,7 @@ fun ThisOrThatScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
cards = state.revealCards,
|
cards = state.revealCards,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onHome = viewModel::quit,
|
onHome = viewModel::quit
|
||||||
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
|
||||||
showNextBeat = true
|
|
||||||
)
|
)
|
||||||
TotPhase.PLAYING -> {
|
TotPhase.PLAYING -> {
|
||||||
val question = state.questions.getOrNull(state.currentIndex)
|
val question = state.questions.getOrNull(state.currentIndex)
|
||||||
|
|
@ -1060,9 +1035,7 @@ private fun ThisOrThatReveal(
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
cards: List<RevealCard>,
|
cards: List<RevealCard>,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit
|
||||||
onOpenDaily: () -> Unit = {},
|
|
||||||
showNextBeat: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
val ratio = if (total > 0) matched.toFloat() / total else 0f
|
val ratio = if (total > 0) matched.toFloat() / total else 0f
|
||||||
val headline = when {
|
val headline = when {
|
||||||
|
|
@ -1129,10 +1102,6 @@ private fun ThisOrThatReveal(
|
||||||
) {
|
) {
|
||||||
Text("Back to Play")
|
Text("Back to Play")
|
||||||
}
|
}
|
||||||
if (showNextBeat) {
|
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1141,11 +1110,7 @@ private fun ThisOrThatReveal(
|
||||||
private fun MatchScoreBadge(matched: Int, total: Int) {
|
private fun MatchScoreBadge(matched: Int, total: Int) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.size(116.dp),
|
||||||
.size(116.dp)
|
|
||||||
.semantics(mergeDescendants = true) {
|
|
||||||
contentDescription = "You matched on $matched of $total"
|
|
||||||
},
|
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = cs.primaryContainer,
|
color = cs.primaryContainer,
|
||||||
shadowElevation = 8.dp
|
shadowElevation = 8.dp
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ fun CategoryPickerScreen(
|
||||||
if (item.isLocked) onNavigate(AppRoute.PAYWALL)
|
if (item.isLocked) onNavigate(AppRoute.PAYWALL)
|
||||||
else onNavigate(AppRoute.spinWheel(item.category.id))
|
else onNavigate(AppRoute.spinWheel(item.category.id))
|
||||||
},
|
},
|
||||||
onHistory = { onNavigate(AppRoute.GAME_HISTORY) },
|
onHistory = { onNavigate(AppRoute.WHEEL_HISTORY) },
|
||||||
onRetry = viewModel::load
|
onRetry = viewModel::load
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onHistory() {
|
fun onHistory() {
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.GAME_HISTORY) }
|
_uiState.update { it.copy(navigateTo = AppRoute.WHEEL_HISTORY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onNavigated() {
|
fun onNavigated() {
|
||||||
|
|
|
||||||
|
|
@ -237,9 +237,7 @@ fun WheelCompleteScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
items = state.items,
|
items = state.items,
|
||||||
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
|
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
|
||||||
onHome = { onNavigate(AppRoute.PLAY) },
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
|
||||||
showNextBeat = true
|
|
||||||
)
|
)
|
||||||
WheelRevealPhase.ERROR -> WheelErrorContent(
|
WheelRevealPhase.ERROR -> WheelErrorContent(
|
||||||
message = state.error ?: "Something went wrong.",
|
message = state.error ?: "Something went wrong.",
|
||||||
|
|
@ -326,9 +324,7 @@ private fun WheelRevealContent(
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
items: List<WheelRevealItem>,
|
items: List<WheelRevealItem>,
|
||||||
onSpinAgain: () -> Unit,
|
onSpinAgain: () -> Unit,
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit
|
||||||
onOpenDaily: () -> Unit = {},
|
|
||||||
showNextBeat: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -394,10 +390,6 @@ private fun WheelRevealContent(
|
||||||
) {
|
) {
|
||||||
Text("Back to Play")
|
Text("Back to Play")
|
||||||
}
|
}
|
||||||
if (showNextBeat) {
|
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,43 +274,6 @@ class ChallengeStateMachineTest {
|
||||||
assertEquals(3, state.currentDay)
|
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
|
// Helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -157,12 +157,7 @@ exports.onGameSessionUpdate = functions.firestore
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ── Session completed (reveal ready for both) ────────────────────────
|
// ── Session completed (reveal ready for both) ────────────────────────
|
||||||
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
|
if (status === 'completed' && !currentData.finishNotifiedAt) {
|
||||||
// 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 claimed = await db.runTransaction(async (tx) => {
|
||||||
const fresh = await tx.get(sessionRef);
|
const fresh = await tx.get(sessionRef);
|
||||||
const d = fresh.data();
|
const d = fresh.data();
|
||||||
|
|
@ -245,20 +240,8 @@ exports.onGamePartFinished = functions.firestore
|
||||||
const finisher = await db.collection('users').doc(finisherUid).get();
|
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 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;
|
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
|
||||||
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
|
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, 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.
|
* Send notification to partner via FCM and write to notification_queue.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -34,11 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
|
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
|
||||||
exports.formatCstDate = formatCstDate;
|
|
||||||
exports.parseCstDate = parseCstDate;
|
|
||||||
exports.timestampAt6PmCst = timestampAt6PmCst;
|
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const CST_OFFSET_HOURS = -6;
|
||||||
/**
|
/**
|
||||||
* Scheduled function that assigns one daily question per couple every day.
|
* Scheduled function that assigns one daily question per couple every day.
|
||||||
*
|
*
|
||||||
|
|
@ -59,9 +57,9 @@ exports.assignDailyQuestion = functions.pubsub
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
const today = cstDateString();
|
const today = cstDateString();
|
||||||
const nextDay = nextCstDateString();
|
const nextDay = nextCstDateString();
|
||||||
const questionId = await pickDailyQuestionId(today);
|
const questionId = await pickRandomQuestionId();
|
||||||
if (!questionId) {
|
if (!questionId) {
|
||||||
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
|
console.error('[assignDailyQuestion] no active questions available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const couplesSnap = await db.collection('couples').get();
|
const couplesSnap = await db.collection('couples').get();
|
||||||
|
|
@ -137,7 +135,7 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
|
||||||
if (date !== today) {
|
if (date !== today) {
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
|
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
|
||||||
}
|
}
|
||||||
const questionId = await pickDailyQuestionId(today);
|
const questionId = await pickRandomQuestionId();
|
||||||
if (!questionId) {
|
if (!questionId) {
|
||||||
throw new functions.https.HttpsError('internal', 'No active questions available.');
|
throw new functions.https.HttpsError('internal', 'No active questions available.');
|
||||||
}
|
}
|
||||||
|
|
@ -175,51 +173,20 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
|
||||||
* so the app still functions during rollout. In production, keep the local
|
* so the app still functions during rollout. In production, keep the local
|
||||||
* Room database and Firestore pool in sync through the existing seed flow.
|
* Room database and Firestore pool in sync through the existing seed flow.
|
||||||
*/
|
*/
|
||||||
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
|
async function pickRandomQuestionId() {
|
||||||
// (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();
|
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
|
const snapshot = await db
|
||||||
.collection('questions')
|
.collection('questions')
|
||||||
.where('active', '==', true)
|
.where('active', '==', true)
|
||||||
.where('isPremium', '==', false)
|
.where('isPremium', '==', false)
|
||||||
.get();
|
.get();
|
||||||
if (snapshot.empty)
|
if (snapshot.empty) {
|
||||||
return null;
|
// Rollout fallback: keep the feature working until the questions pool is seeded.
|
||||||
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay();
|
return 'q_default_daily';
|
||||||
const modeTag = WEEKDAY_MODE_TAGS[weekday];
|
}
|
||||||
const inMode = snapshot.docs
|
const docs = snapshot.docs;
|
||||||
.filter((d) => d.get('modeTag') === modeTag)
|
const chosen = docs[Math.floor(Math.random() * docs.length)];
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
return chosen.id;
|
||||||
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.
|
* Returns today's date as YYYY-MM-DD in America/Chicago time.
|
||||||
|
|
@ -236,47 +203,35 @@ function nextCstDateString(from) {
|
||||||
d.setUTCDate(d.getUTCDate() + 1);
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
return formatCstDate(d);
|
return formatCstDate(d);
|
||||||
}
|
}
|
||||||
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
|
const d = new Date();
|
||||||
const todayChicago = parseCstDate(formatCstDate(new Date()));
|
const cst = applyCstOffset(d);
|
||||||
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
|
cst.setUTCDate(cst.getUTCDate() + 1);
|
||||||
return formatCstDate(todayChicago);
|
return formatCstDate(cst);
|
||||||
}
|
}
|
||||||
/**
|
function applyCstOffset(d) {
|
||||||
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
|
const utcMs = d.getTime();
|
||||||
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
|
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000;
|
||||||
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
|
return new Date(cstMs);
|
||||||
* 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) {
|
function formatCstDate(d) {
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
const cst = applyCstOffset(d);
|
||||||
timeZone: 'America/Chicago',
|
const y = cst.getUTCFullYear();
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
const m = String(cst.getUTCMonth() + 1).padStart(2, '0');
|
||||||
}).format(d);
|
const day = String(cst.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
/** The UTC instant of midnight America/Chicago on the given calendar date. */
|
|
||||||
function parseCstDate(dateStr) {
|
function parseCstDate(dateStr) {
|
||||||
const [y, m, day] = dateStr.split('-').map(Number);
|
const [y, m, day] = dateStr.split('-').map(Number);
|
||||||
// Guess with the standard offset, then correct with the real offset at that instant.
|
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
|
||||||
const guessMs = Date.UTC(y, m - 1, day, 6);
|
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0);
|
||||||
const offset = chicagoOffsetHours(guessMs);
|
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000);
|
||||||
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) {
|
function timestampAt6PmCst(dateStr) {
|
||||||
const [y, m, day] = dateStr.split('-').map(Number);
|
const d = parseCstDate(dateStr);
|
||||||
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
|
const utcMs = d.getTime();
|
||||||
const offset = chicagoOffsetHours(guessMs);
|
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
|
||||||
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
|
// We already have midnight CST in UTC form; add 18 hours.
|
||||||
|
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000;
|
||||||
|
return admin.firestore.Timestamp.fromMillis(at6pmCstMs);
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=assignDailyQuestion.js.map
|
//# sourceMappingURL=assignDailyQuestion.js.map
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,38 +0,0 @@
|
||||||
"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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"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,12 +134,7 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session completed (reveal ready for both) ────────────────────────
|
// ── Session completed (reveal ready for both) ────────────────────────
|
||||||
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
|
if (status === 'completed' && !currentData.finishNotifiedAt) {
|
||||||
// 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 claimed = await db.runTransaction(async (tx) => {
|
||||||
const fresh = await tx.get(sessionRef)
|
const fresh = await tx.get(sessionRef)
|
||||||
const d = fresh.data()
|
const d = fresh.data()
|
||||||
|
|
@ -228,24 +223,11 @@ export const onGamePartFinished = functions.firestore
|
||||||
|
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, recipient, finisherName, gameType,
|
db, messaging, recipient, finisherName, gameType,
|
||||||
'partner_completed_part', yourTurnBody(gameType), coupleId,
|
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId,
|
||||||
finisherAvatar, sessionId
|
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.
|
* Send notification to partner via FCM and write to notification_queue.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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,6 +1,7 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
const CST_OFFSET_HOURS = -6
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled function that assigns one daily question per couple every day.
|
* Scheduled function that assigns one daily question per couple every day.
|
||||||
|
|
@ -23,9 +24,9 @@ export const assignDailyQuestion = functions.pubsub
|
||||||
const today = cstDateString()
|
const today = cstDateString()
|
||||||
const nextDay = nextCstDateString()
|
const nextDay = nextCstDateString()
|
||||||
|
|
||||||
const questionId = await pickDailyQuestionId(today)
|
const questionId = await pickRandomQuestionId()
|
||||||
if (!questionId) {
|
if (!questionId) {
|
||||||
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
|
console.error('[assignDailyQuestion] no active questions available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +110,7 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
|
||||||
if (date !== today) {
|
if (date !== today) {
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
|
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
|
||||||
}
|
}
|
||||||
const questionId = await pickDailyQuestionId(today)
|
const questionId = await pickRandomQuestionId()
|
||||||
if (!questionId) {
|
if (!questionId) {
|
||||||
throw new functions.https.HttpsError('internal', 'No active questions available.')
|
throw new functions.https.HttpsError('internal', 'No active questions available.')
|
||||||
}
|
}
|
||||||
|
|
@ -150,53 +151,22 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
|
||||||
* so the app still functions during rollout. In production, keep the local
|
* so the app still functions during rollout. In production, keep the local
|
||||||
* Room database and Firestore pool in sync through the existing seed flow.
|
* Room database and Firestore pool in sync through the existing seed flow.
|
||||||
*/
|
*/
|
||||||
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
|
async function pickRandomQuestionId(): Promise<string | null> {
|
||||||
// (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()
|
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
|
const snapshot = await db
|
||||||
.collection('questions')
|
.collection('questions')
|
||||||
.where('active', '==', true)
|
.where('active', '==', true)
|
||||||
.where('isPremium', '==', false)
|
.where('isPremium', '==', false)
|
||||||
.get()
|
.get()
|
||||||
if (snapshot.empty) return null
|
|
||||||
|
|
||||||
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
|
if (snapshot.empty) {
|
||||||
const modeTag = WEEKDAY_MODE_TAGS[weekday]
|
// Rollout fallback: keep the feature working until the questions pool is seeded.
|
||||||
const inMode = snapshot.docs
|
return 'q_default_daily'
|
||||||
.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
|
const docs = snapshot.docs
|
||||||
? inMode
|
const chosen = docs[Math.floor(Math.random() * docs.length)]
|
||||||
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback
|
return chosen.id
|
||||||
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
|
|
||||||
return pool[offset].id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,50 +185,38 @@ function nextCstDateString(from?: string): string {
|
||||||
d.setUTCDate(d.getUTCDate() + 1)
|
d.setUTCDate(d.getUTCDate() + 1)
|
||||||
return formatCstDate(d)
|
return formatCstDate(d)
|
||||||
}
|
}
|
||||||
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
|
const d = new Date()
|
||||||
const todayChicago = parseCstDate(formatCstDate(new Date()))
|
const cst = applyCstOffset(d)
|
||||||
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1)
|
cst.setUTCDate(cst.getUTCDate() + 1)
|
||||||
return formatCstDate(todayChicago)
|
return formatCstDate(cst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function applyCstOffset(d: Date): Date {
|
||||||
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
|
const utcMs = d.getTime()
|
||||||
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
|
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000
|
||||||
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
|
return new Date(cstMs)
|
||||||
* 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
|
function formatCstDate(d: Date): string {
|
||||||
export function formatCstDate(d: Date): string {
|
const cst = applyCstOffset(d)
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
const y = cst.getUTCFullYear()
|
||||||
timeZone: 'America/Chicago',
|
const m = String(cst.getUTCMonth() + 1).padStart(2, '0')
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
const day = String(cst.getUTCDate()).padStart(2, '0')
|
||||||
}).format(d)
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The UTC instant of midnight America/Chicago on the given calendar date. */
|
function parseCstDate(dateStr: string): Date {
|
||||||
export function parseCstDate(dateStr: string): Date {
|
|
||||||
const [y, m, day] = dateStr.split('-').map(Number)
|
const [y, m, day] = dateStr.split('-').map(Number)
|
||||||
// Guess with the standard offset, then correct with the real offset at that instant.
|
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
|
||||||
const guessMs = Date.UTC(y, m - 1, day, 6)
|
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0)
|
||||||
const offset = chicagoOffsetHours(guessMs)
|
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000)
|
||||||
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: string): admin.firestore.Timestamp {
|
||||||
export function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
|
const d = parseCstDate(dateStr)
|
||||||
const [y, m, day] = dateStr.split('-').map(Number)
|
const utcMs = d.getTime()
|
||||||
const guessMs = Date.UTC(y, m - 1, day, 18 + 6)
|
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
|
||||||
const offset = chicagoOffsetHours(guessMs)
|
// We already have midnight CST in UTC form; add 18 hours.
|
||||||
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset))
|
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000
|
||||||
|
return admin.firestore.Timestamp.fromMillis(at6pmCstMs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,3 @@ NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversat
|
||||||
- coupleId `Xal3Kw3gjSdn0niERYKJ`
|
- coupleId `Xal3Kw3gjSdn0niERYKJ`
|
||||||
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
|
- 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`.
|
- 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