From b2dc96ca04f05ebac4ccc87c19dbcc8198a3340f Mon Sep 17 00:00:00 2001 From: null Date: Mon, 29 Jun 2026 11:02:31 -0500 Subject: [PATCH] feat(games): GamePromptBanner UI + MessageBubbleOverlay polish, wheel bounce fix, GameCopy strings, QA coverage updates --- ClaudeQACoverage.md | 4 +- ClaudeReport.md | 2 + app/src/main/AndroidManifest.xml | 1 + .../notifications/GamePromptController.kt | 11 +- .../closer/ui/components/GamePromptBanner.kt | 113 ++++++++++++------ .../ui/components/MessageBubbleOverlay.kt | 9 ++ .../closer/ui/desiresync/DesireSyncScreen.kt | 6 +- .../main/java/app/closer/ui/games/GameCopy.kt | 7 ++ .../app/closer/ui/howwell/HowWellScreen.kt | 6 +- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 6 +- .../app/closer/ui/wheel/WheelSessionScreen.kt | 66 +++++++++- .../closer/ui/wheel/WheelSessionViewModel.kt | 44 ++++++- .../ui/wheel/WheelSessionViewModelTest.kt | 29 +++++ firestore.rules | 13 ++ 14 files changed, 267 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/games/GameCopy.kt diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index f59a1208..614f7d6a 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -1,7 +1,7 @@ # Claude QA Coverage Matrix > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. -> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); 209 unit + 24 functions tests green; debug APK rebuilt+installed both emulators. **⚠ `functions/` + `firestore.rules` must be deployed by the user** for the join push to fire live. Position + verdict: see `ClaudeReport.md` R18b run-state. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** +> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **⚠ DEPLOY (user):** `functions/` for the join push, and `firestore.rules` for **both** the join allowlist **and** the new Tier-2 self-constraint (a member may only add their own uid to `completedByUsers`/`joinedByUsers`). Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** > > **Scope expanded (plan review):** the playbook now has first-class passes **K–O** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓". > @@ -15,7 +15,7 @@ | Pass | Coverage | Status | |---|---|---| | A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) | -| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). | ✅ pass · finish-gate verified live (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | +| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. | ✅ pass · finish-gate + submit-retry + abandon verified (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) | | C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED** — `MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` | | D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds | | E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device | diff --git a/ClaudeReport.md b/ClaudeReport.md index a71b2c56..fa32f916 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,6 +18,8 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) +- **R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation).** Baseline reset clean: both emulators paired, both **free** (admin-confirmed premium=false), **0 active sessions** (cleared the one stale ToT by completing it 10/10). **Cheap gates ALL GREEN:** `./gradlew testDebugUnitTest` **210**, `cd functions && npm test` **24**, `scripts/theme-scan.sh` **CRITICAL 0** (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), `scripts/painter-xml-scan.sh` **0**, `qa/entrypoint_smoke.sh` **6/6 on both emulators**. **Cornerstones live:** **A ✅** premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). **B ✅** This-or-That full 2-device → completed **10/10 "in sync"** reveal (+ this round's finish-gate / submit-retry / Quit-abandon). **D ✅** security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement **all DENIED 403**; messages at-rest `enc:v1:`; session/answer docs carry no plaintext. **E ✅** notifications: cold-start smoke 6/6 both + `partner_joined_game` live end-to-end (deployed) + standardized durable banner. **0 FATAL.** Remaining passes **carry their recent-round status** (per `ClaudeQACoverage.md`, code-stable — this round's diff is additive client + the deploy-gated rule): **C** theme-scan CRIT 0 + this-session dark spot-checks; **F** offline-cache (carried) + new Wheel submit-retry (unit-tested); **L** chat at-rest `enc:v1:` confirmed; **G/H/I/J/N/P** carried; **K** money-path + **O** release + Doze/battery = `blocked→needs-device` (pre-ship). **Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked.** ⚠ **Deploy still needed (user):** `firestore.rules` Tier-2 self-constraint. +- **R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate").** Bundled with the modern-feel polish. **Error handling:** **E1 (P2 bug) FIXED** — `WheelSessionViewModel.submitAndFinish` used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a **retryable error** (`submitFailed`/`error` + a `SubmitErrorCard` "Retry", no false reveal), matching ToT/DesireSync/HowWell. **E3** — the silently-swallowed join-writes now `.onFailure { Log.w }` in all 4 games. **Consolidation (targeted):** deduped the 3 copies of the save-error string into `ui/games/GameCopy.SAVE_ANSWERS_ERROR` (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). **Modern feel:** `GamePromptBanner` — soft **haptic on arrival** (reuses `CelebrationOverlay` `LocalHapticFeedback`), **spring overshoot** entrance, **live green presence dot** on the avatar for JOINED, **whole-card tap-to-act + swipe-up dismiss**, `liveRegion` + `contentDescription` a11y, and `GamePromptController` now **won't let a transient (started/joined) clobber a persistent (your-turn/results)** banner; **chat bubble** got the same arrival haptic for parity; warmer brand-voice copy (banner `styleFor` = client source of truth, cross-commented with the function). **Predictive back (2026):** `android:enableOnBackInvokedCallback="true"` (safe — 0 `onBackPressed`/`BackHandler` in the app). **Tier 3:** the Wheel active screen got a **"Quit game"** escape hatch (`abandon()`→`abandonSession`) so leaving mid-wheel no longer strands a session (mirrors ToT). **Verified:** build + **210 Android unit (+1 new Wheel submit-retry test) + 24 functions** green; **live (5554):** JOINED banner shows warm copy + green presence dot + real avatar; **Pass-E cold-start smoke 6/6 on BOTH emulators** (notification path regression-clean); 0 FATAL. **⚠ DEPLOY REQUIRED (user) — Tier 2 security:** `firestore.rules` now constrains `completedByUsers`/`joinedByUsers` so a member can only add **their own** uid (no spoofing the partner's join/completion, no removals; `get(...,[])` tolerates old docs) — **needs `firebase deploy --only firestore:rules` + a rules test** before it takes effect (compatible with markUserComplete/markUserJoined/abandonSession by construction). Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `WheelSessionViewModelTest.kt`, `GamePromptBanner.kt`, `GamePromptController.kt`, `MessageBubbleOverlay.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `ui/games/GameCopy.kt`, `AndroidManifest.xml`, `firestore.rules`, `functions/src/games/onGameSessionUpdate.ts` (sender_name), `ClaudeReport.md`, `ClaudeQACoverage.md`. **NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).** - **R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard").** Decision (discussed): **"Standardize, keep durable."** **New `partner_joined_game`:** the non-starter opening an active session writes their uid to `joinedByUsers` (new field; client `markUserJoined` mirrors `markUserComplete`; `firestore.rules` session allowlist += `joinedByUsers`, server-only `joinNotifiedAt` deliberately excluded); `onGameSessionUpdate` adds a branch that claims a one-time `joinNotifiedAt` and notifies the **starter** " joined your game" with the **joiner's avatar**. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell `joinSession` + Wheel `load()` resume), best-effort + off the critical path (guarded to never fire for the starter). **Standardized banner:** generalized `GamePromptController`/`GamePromptBanner` with a `kind` (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; **STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent** (stay until tapped/dismissed). `AppMessagingService` now routes all 4 game types to the banner in the **foreground** (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); `AppNavigation` routes the banner action by kind (RESULTS→per-session results, else→the game). Added `sender_name` to the game push `data` so the banner names the partner. **Build + 209 Android unit + 24 functions tests green** (added a `PartnerNotificationTypeTest` case for the new type's mapping + routing). **Live (5554, foreground, real avatar+name):** STARTED "Sam started a game"+Join (avatar loaded), **JOINED "Sam joined your game"+View**, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — **RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient)**; **0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL.** **Pass-E regression smoke 6/6 on BOTH emulators** (shared cold-start path clean). **⚠ DEPLOY REQUIRED (user):** the `partner_joined_game` push only fires once `functions/` + `firestore.rules` are deployed to `closer-app-22014` (I can't deploy; prior prod writes were classifier-denied) — until then the `joinedByUsers` client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): `QuestionSession.kt`, `QuestionSessionRepository.kt`(+Impl), `GameSessionManager.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `WheelSessionViewModel.kt`, `GamePromptController.kt`, `GamePromptBanner.kt`, `AppMessagingService.kt`, `PartnerNotificationManager.kt`, `AppNavigation.kt`, `PartnerNotificationTypeTest.kt`, `functions/src/games/onGameSessionUpdate.ts`, `firestore.rules`, `Future.md`, `ClaudeReport.md`. - **R18b (2026-06-28) — cleanup + backlog prune (user: "clean up and work on what you can from ClaudeReport.md and Future.md").** **C-ORIENT-001 (P3) → FIXED + verified live:** added `android:screenOrientation="portrait"` to `MainActivity` (no landscape design exists — 0 `*-land` resource dirs); under forced device landscape the activity holds `requestedOrientation=SCREEN_ORIENTATION_PORTRAIT` and renders upright. **Re-confirmed the test gate:** 208 Android unit + 24 functions tests green (re-validates TEST-001/TEST-002 + no regression from the Wheel + manifest changes). **Live-confirmed the last unverified theme fixes in dark (5554):** Date Match heart "View matches" button + match-count badge (C-THEME-008/009) render on `primaryContainer`/`error` (no light-on-light); C-THEME-004/005 confirmed via theme-scan CRITICAL=0 + same-batch sibling live-confirm + build (direct view blocked by a residual active session). **Pruned the entire confirmed backlog** to the archived line (11 IDs + C-ORIENT-001) per the one-confirmation-round rule — **open issues now just 2, both blocked on the user: O-AGE-001 (P2, product/legal age gate) + BRAND-DARK-COVERAGE (P3, needs dark art assets).** Triaged the rest of Future.md as user/device-blocked (release config needs real version+legal URLs+RC key; biometric re-lock-on-background is a UX call + untestable without an enrolled biometric; App Check excluded in dev; proactive-notif/instrumented-smoke/screenshot-diff/skeletons/help-surface are larger features). **Residual test-data:** one active This-or-That session (Sam) created during reinstalls — couldn't clear cleanly (Quit/End-their-game don't cancel server-side; admin write denied; pm-clear forbidden). Uncommitted (user commits): `AndroidManifest.xml`, `ClaudeReport.md`. - **R18b (2026-06-28) — FEATURE: games must be fully answered before finishing (user: "if a user skips a question it makes the user go back and answer it before the game is over … for all games").** Grounding found the four Play-hub games use different models and **only Spin the Wheel** let a player finish with blanks (explicit Skip, `Next` advanced when blank, `End session` submitted the rest as "Skipped", and it's the only game with text boxes); **This or That / Desire Sync / How Well already require a pick to advance** (verified by code — `select()` needs an option / `commitAnswer` guard + `enabled = hasSelection`). Per user decision **"Hybrid"**: implement skip-then-must-complete on the Wheel; leave the other three (no forced skip affordance). **Wheel change (`WheelSessionViewModel.kt` + `WheelSessionScreen.kt`):** answers are now an index-keyed nullable list; `skip()`/blank-`next()` leave a slot `null`; the new **`attemptFinish()` gate** submits only when no slot is `null`, else bounces to the first unanswered prompt and shows a "N questions left — answer them to finish" banner (a11y `liveRegion`); `End session`→`Finish now` (gated); enforces non-empty text + ≥1 choice via the existing `hasValidSelection()`. Category-picker copy updated. **Verified:** build + **205→ unit tests green incl. 3 new `WheelSessionViewModelTest`** (gaps→bounce/no-submit; all-answered→submit with no "Skipped"; completion-walk). **Live (emulator-5554, fresh wheel):** `Finish now` with all blank → banner "10 questions left" + stayed on Q1 (no submit); answered Q1 + `Finish` → jumped to Q2, banner "9 questions left" — gate + banner + walk-forward + text-box enforcement all confirmed; 0 FATAL. **Full end-to-end (both emulators):** played a complete Spin the Wheel on QA **and** Sam (all 10 prompts, mixed written/choice) → session completed → reveal shows both players' real answers with **no "Skipped"**; then played a complete **This or That** on both (5/5 "in sync" reveal) — confirming a second game is fully playable once the wheel is done and ToT requires a pick to advance (no skip). Ended at **0 active sessions** (clean). How Well / Desire Sync left unverified-live (Desire Sync is premium→paywall; How Well unchanged) — both verified by code (require a selection to advance). Adjacent checks (no change): multi_choice has no `minSelections` (≥1 is correct); Daily Question already gated (`canSubmit`); reveal renders legacy "Skipped" as `display ?: "—"` (no crash). **Test-data notes:** cleared a stale stuck wheel session via the in-app reveal→`markUserComplete` path (admin write to flip it was **classifier-denied**, not worked around); live testing then created one new active wheel session (net-neutral) which blocked live-opening the other 3 games — those are verified by code (unchanged) + observed during Pass E. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `CategoryPickerScreen.kt`, `WheelSessionViewModelTest.kt`, `ClaudeReport.md`. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 56dfe745..e2574e4f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:name=".CloserApp" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" diff --git a/app/src/main/java/app/closer/notifications/GamePromptController.kt b/app/src/main/java/app/closer/notifications/GamePromptController.kt index 93bf4e3e..315de9ff 100644 --- a/app/src/main/java/app/closer/notifications/GamePromptController.kt +++ b/app/src/main/java/app/closer/notifications/GamePromptController.kt @@ -11,7 +11,12 @@ import javax.inject.Singleton * whether it auto-dismisses. STARTED/JOINED are transient presence nudges; YOUR_TURN/RESULTS are * durable (persist until tapped/dismissed) because they're easy-to-miss, act-on-it moments. */ -enum class GamePromptKind { STARTED, JOINED, YOUR_TURN, RESULTS } +enum class GamePromptKind(val persistent: Boolean) { + STARTED(persistent = false), + JOINED(persistent = false), + YOUR_TURN(persistent = true), + RESULTS(persistent = true), +} /** * State for the in-app game banner. Shown over the app's own UI when a game push arrives while the @@ -41,6 +46,10 @@ class GamePromptController @Inject constructor() { avatarUrl: String? ) { if (coupleId.isBlank() || gameType.isBlank()) return + // Don't let a transient presence nudge (started/joined) clobber a persistent banner + // (your turn / results) the user hasn't acted on yet. + val current = _prompt.value + if (current != null && current.kind.persistent && !kind.persistent) return _prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl) } diff --git a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt index ce00f68f..47e89eeb 100644 --- a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt +++ b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt @@ -5,6 +5,17 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -79,16 +90,18 @@ fun GamePromptBanner( viewModel: GamePromptViewModel = hiltViewModel() ) { val prompt by viewModel.prompt.collectAsState() + val haptics = LocalHapticFeedback.current // Retain the last prompt so the slide-out animation still has data to render. var last by remember { mutableStateOf(null) } LaunchedEffect(prompt) { if (prompt != null) last = prompt } - // Presence nudges (started/joined) auto-dismiss; act-on-it banners (your turn / results) persist - // until tapped or dismissed so they're never missed. + // A partner action is an emotional beat — give it a soft haptic on arrival. Then: presence nudges + // (started/joined) auto-dismiss; act-on-it banners (your turn / results) persist until tapped. LaunchedEffect(prompt) { - val p = prompt - if (p != null && !styleFor(p).persistent) { + val p = prompt ?: return@LaunchedEffect + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + if (!p.kind.persistent) { delay(9000) viewModel.dismiss() } @@ -97,7 +110,14 @@ fun GamePromptBanner( Box(modifier = Modifier.fillMaxSize()) { AnimatedVisibility( visible = prompt != null, - enter = slideInVertically { -it } + fadeIn(), + // Gentle overshoot spring (matches the app's PairingSuccess/SpinWheel motion) — feels alive, + // not like a system toast. + enter = slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) { -it } + fadeIn(), exit = slideOutVertically { -it } + fadeOut(), modifier = Modifier.align(Alignment.TopCenter) ) { @@ -114,18 +134,19 @@ fun GamePromptBanner( private data class PromptStyle( val line1: String, val line2: String, - val action: String, - val persistent: Boolean + val action: String ) +// Single client source of truth for the banner copy. Mirror any change in the Cloud Function copy +// (functions/src/games/onGameSessionUpdate.ts notifyPartner) so foreground/background stay in sync. private fun styleFor(prompt: IncomingGamePrompt): PromptStyle { val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner" val game = gameDisplayName(prompt.gameType) return when (prompt.kind) { - GamePromptKind.STARTED -> PromptStyle("$name started a game", game, "Join", persistent = false) - GamePromptKind.JOINED -> PromptStyle("$name joined your game", game, "View", persistent = false) - GamePromptKind.YOUR_TURN -> PromptStyle("$name finished their part", "Your turn", "Play", persistent = true) - GamePromptKind.RESULTS -> PromptStyle("$name finished", "See your results", "View", persistent = true) + GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join") + GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View") + GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play") + GamePromptKind.RESULTS -> PromptStyle("You both finished", "See how you and $name compare", "View") } } @@ -136,6 +157,7 @@ private fun GamePromptCard( onDismiss: () -> Unit ) { val style = styleFor(prompt) + var dragUp by remember(prompt.kind, prompt.gameSessionId) { mutableStateOf(0f) } Row( modifier = Modifier .statusBarsPadding() @@ -149,32 +171,55 @@ private fun GamePromptCard( ) ) .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(20.dp)) + // Whole card taps act; flick up to dismiss (mirrors the chat bubble's drag-to-dismiss). + .clickable(onClick = onAction) + .pointerInput(prompt.gameSessionId) { + detectVerticalDragGestures( + onDragEnd = { if (dragUp < -60f) onDismiss() else dragUp = 0f }, + onVerticalDrag = { _, dy -> dragUp += dy } + ) + } + .semantics { + liveRegion = LiveRegionMode.Polite + contentDescription = "${style.line1}. ${style.line2}." + } .padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Partner avatar (falls back to a play glyph). - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.18f)) - .border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape), - contentAlignment = Alignment.Center - ) { - val avatar = prompt.avatarUrl - if (!avatar.isNullOrBlank()) { - AsyncImage( - model = avatar, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(40.dp).clip(CircleShape) - ) - } else { - Icon( - imageVector = CloserGlyphs.Play, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(22.dp) + // Partner avatar (falls back to a play glyph) + a live "here now" dot when they JOIN. + Box(contentAlignment = Alignment.BottomEnd) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + val avatar = prompt.avatarUrl + if (!avatar.isNullOrBlank()) { + AsyncImage( + model = avatar, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(CircleShape) + ) + } else { + Icon( + imageVector = CloserGlyphs.Play, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + } + if (prompt.kind == GamePromptKind.JOINED) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(Color(0xFF3DD68C)) // live "here now" green + .border(2.dp, CloserPalette.PurpleDeep, CircleShape) ) } } diff --git a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt index d9058e99..ab4bc844 100644 --- a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt +++ b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt @@ -15,7 +15,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -62,6 +65,12 @@ fun MessageBubbleOverlay( val bubble by viewModel.bubble.collectAsState() val current = bubble ?: return + // A soft haptic when a new chat-head arrives (parity with the game banner's "felt" arrival). + val haptics = LocalHapticFeedback.current + LaunchedEffect(current.conversationId) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + val density = LocalDensity.current val bubbleSize = 60.dp val margin = 14.dp diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index b48060af..fbdd51a7 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -65,6 +65,7 @@ import app.closer.domain.model.SessionLength import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.games.GameCopy import dagger.hilt.android.qualifiers.ApplicationContext import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.StatusGlyph @@ -233,7 +234,8 @@ class DesireSyncViewModel @Inject constructor( // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + // no-op for the starter server-side). if (cId != null) viewModelScope.launch { - runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + gameSessionManager.markUserJoined(existingSessionId, cId) + .onFailure { Log.w(TAG, "markUserJoined failed", it) } } val alreadyAnswered = uid != null && cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } @@ -294,7 +296,7 @@ class DesireSyncViewModel @Inject constructor( .onFailure { e -> submitted = false Log.w(TAG, "Could not submit answers", e) - _uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) } + _uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = GameCopy.SAVE_ANSWERS_ERROR, submitFailed = true) } } // The observer flips WAITING → REVEAL once the partner's answers land. } diff --git a/app/src/main/java/app/closer/ui/games/GameCopy.kt b/app/src/main/java/app/closer/ui/games/GameCopy.kt new file mode 100644 index 00000000..56aa1ebf --- /dev/null +++ b/app/src/main/java/app/closer/ui/games/GameCopy.kt @@ -0,0 +1,7 @@ +package app.closer.ui.games + +/** Shared, de-duplicated copy for the game session flows (This or That / Desire Sync / How Well / Wheel). */ +object GameCopy { + /** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */ + const val SAVE_ANSWERS_ERROR = "Couldn't save your answers. Check your connection and try again." +} diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index f4cdcc0f..df10bf9a 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -72,6 +72,7 @@ import app.closer.data.remote.FirestoreHowWellDataSource import app.closer.data.remote.HowWellAnswers import app.closer.data.remote.HowWellRawAnswer import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.games.GameCopy import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.ResultGlyph import app.closer.ui.components.StatusGlyph @@ -272,7 +273,8 @@ class HowWellViewModel @Inject constructor( // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + // no-op for the starter server-side). if (cId != null && uid != startedBy) viewModelScope.launch { - runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + gameSessionManager.markUserJoined(existingSessionId, cId) + .onFailure { Log.w(TAG, "markUserJoined failed", it) } } val alreadyAnswered = cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } @@ -321,7 +323,7 @@ class HowWellViewModel @Inject constructor( .onFailure { e -> submitted = false Log.w(TAG, "Could not submit answers", e) - _uiState.update { it.copy(phase = HowWellPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) } + _uiState.update { it.copy(phase = HowWellPhase.ERROR, error = GameCopy.SAVE_ANSWERS_ERROR, submitFailed = true) } } // The observer flips WAITING → COMPLETE once the partner's answers land. } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index dbbe9f91..ab2fd4de 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -80,6 +80,7 @@ import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.games.GameCopy import app.closer.ui.components.BrandMessageRotator import android.content.Context import android.provider.Settings @@ -279,7 +280,8 @@ class ThisOrThatViewModel @Inject constructor( // Tell the starter "your partner joined" (best-effort, off the critical path; idempotent + // no-op for the starter server-side). if (cId != null) viewModelScope.launch { - runCatching { gameSessionManager.markUserJoined(existingSessionId, cId) } + gameSessionManager.markUserJoined(existingSessionId, cId) + .onFailure { Log.w(TAG, "markUserJoined failed", it) } } val alreadyAnswered = uid != null && cId != null && runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true } @@ -347,7 +349,7 @@ class ThisOrThatViewModel @Inject constructor( .onFailure { e -> submitted = false Log.w(TAG, "Could not submit answers", e) - _uiState.update { it.copy(phase = TotPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) } + _uiState.update { it.copy(phase = TotPhase.ERROR, error = GameCopy.SAVE_ANSWERS_ERROR, submitFailed = true) } } // The observer flips WAITING → REVEAL once the partner's answers land // (or right away, if they finished first). diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt index 48e7504e..d4165b2a 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt @@ -86,7 +86,9 @@ fun WheelSessionScreen( onWrittenTextChanged = viewModel::onWrittenTextChanged, onNext = viewModel::next, onSkip = viewModel::skip, - onEnd = viewModel::attemptFinish + onEnd = viewModel::attemptFinish, + onRetry = viewModel::retrySubmit, + onAbandon = viewModel::abandon ) } @@ -98,7 +100,9 @@ private fun WheelSessionContent( onWrittenTextChanged: (String) -> Unit, onNext: () -> Unit, onSkip: () -> Unit, - onEnd: () -> Unit + onEnd: () -> Unit, + onRetry: () -> Unit, + onAbandon: () -> Unit ) { Box( modifier = Modifier @@ -123,6 +127,14 @@ private fun WheelSessionContent( return@Column } + if (state.submitFailed) { + SubmitErrorCard( + message = state.error ?: "Couldn't save your answers.", + onRetry = onRetry + ) + return@Column + } + if (state.isEmpty) { EmptySessionCard() return@Column @@ -279,6 +291,18 @@ private fun WheelSessionContent( Text("Finish now", color = Color(0xFF9B8AA6)) } } + // Escape hatch: leave without finishing (force-completes the shared session so it + // isn't left stranded). De-emphasized so it's not confused with Skip/Finish. + TextButton( + onClick = onAbandon, + modifier = Modifier.fillMaxWidth().heightIn(min = 44.dp) + ) { + Text( + "Quit game", + color = Color(0xFF9B8AA6).copy(alpha = 0.7f), + style = MaterialTheme.typography.labelMedium + ) + } } } } @@ -524,6 +548,40 @@ private fun EmptySessionCard() { } } +@Composable +private fun SubmitErrorCard(message: String, onRetry: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.84f)) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + "Couldn't save your answers", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F)) + ) { + Text("Retry", color = Color.White) + } + } + } +} + @Preview @Composable fun WheelSessionScreenPreview() { @@ -542,6 +600,8 @@ fun WheelSessionScreenPreview() { onWrittenTextChanged = {}, onNext = {}, onSkip = {}, - onEnd = {} + onEnd = {}, + onRetry = {}, + onAbandon = {} ) } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index 5a009746..a80ef9a9 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -14,6 +14,7 @@ import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.games.GameCopy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -39,6 +40,9 @@ data class WheelSessionUiState( val categoryName: String = "", val navigateTo: String? = null, val isEmpty: Boolean = false, + /** Set when the final submit fails — the player stays put with a retryable error (no false reveal). */ + val submitFailed: Boolean = false, + val error: String? = null, val selectedOptionIds: List = emptyList(), val selectedScaleValue: Int = 3, val writtenText: String = "" @@ -104,7 +108,8 @@ class WheelSessionViewModel @Inject constructor( // session (best-effort, off the critical path; idempotent + no-op for the starter). if (session != null && uid != session.startedByUserId) { viewModelScope.launch { - runCatching { gameSessionManager.markUserJoined(sessionId, couple.id) } + gameSessionManager.markUserJoined(sessionId, couple.id) + .onFailure { Log.w(TAG, "markUserJoined failed", it) } } } // Load the exact spun set from its fixed id list so both partners answer the @@ -260,7 +265,7 @@ class WheelSessionViewModel @Inject constructor( WheelAnswerEntry(questionId = q.id, display = state.answers.getOrNull(i) ?: SKIPPED) } viewModelScope.launch { - runCatching { + val result = runCatching { answerDataSource.submitAnswers( coupleId = cId, sessionId = sessionId, @@ -269,8 +274,39 @@ class WheelSessionViewModel @Inject constructor( questions = questionRefs, answers = entries ) - }.onFailure { Log.w(TAG, "Could not submit wheel answers", it) } - _uiState.update { it.copy(navigateTo = AppRoute.wheelComplete(sessionId)) } + } + if (result.isFailure) { + // Do NOT navigate to the reveal — a false "saved" would lose answers + strand the session. + // Surface a retryable error (mirrors ThisOrThat/DesireSync/HowWell submit handling). + Log.w(TAG, "Could not submit wheel answers", result.exceptionOrNull()) + submitting = false + _uiState.update { it.copy(submitFailed = true, error = GameCopy.SAVE_ANSWERS_ERROR) } + } else { + _uiState.update { it.copy(navigateTo = AppRoute.wheelComplete(sessionId)) } + } + } + } + + /** Re-attempt the final submit after a failure (the player's [answers] are still in state). */ + fun retrySubmit() { + if (submitting) return + _uiState.update { it.copy(submitFailed = false, error = null) } + submitAndFinish() + } + + /** + * Leave the wheel without finishing — force-completes the shared session so it isn't left stranded + * (mirrors `ThisOrThatViewModel.quit()` / the WheelComplete abandon). Best-effort; always navigates out. + */ + fun abandon() { + val cId = coupleId + if (cId == null) { + _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } + return + } + viewModelScope.launch { + gameSessionManager.abandonSession(cId).onFailure { Log.w(TAG, "abandon failed", it) } + _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } } } diff --git a/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt b/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt index 8b1e6573..d9e6d38a 100644 --- a/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt +++ b/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt @@ -72,6 +72,7 @@ class WheelSessionViewModelTest { coEvery { gameSessionManager.getActiveSession("couple1") } returns QuestionSession(questionIds = listOf("q1", "q2")) coEvery { answerDataSource.getDoc("couple1", "sess1") } returns null + coEvery { gameSessionManager.markUserJoined(any(), any()) } returns Result.success(Unit) coEvery { repository.getQuestionById("q1") } returns writtenQ coEvery { repository.getQuestionById("q2") } returns choiceQ coEvery { repository.getCategoryById(any()) } returns null @@ -157,4 +158,32 @@ class WheelSessionViewModelTest { assertEquals(0, vm.uiState.value.unansweredCount) assertEquals(AppRoute.wheelComplete("sess1"), vm.uiState.value.navigateTo) } + + @Test + fun `submit failure surfaces a retryable error without navigating then retry resubmits`() = runTest(dispatcher) { + val vm = createViewModel() + advanceUntilIdle() + + vm.onWrittenTextChanged("hello") + vm.next() // saves q1, advance to the choice prompt + vm.selectOption("optA") + + // Final submit fails (e.g. offline) → must NOT navigate to the reveal (no false "saved"). + coEvery { + answerDataSource.submitAnswers(any(), any(), any(), any(), any(), any()) + } throws RuntimeException("offline") + vm.next() // last prompt → gate → submit fails + advanceUntilIdle() + assertTrue(vm.uiState.value.submitFailed) + assertNull(vm.uiState.value.navigateTo) + + // Reconnect + retry → submits and navigates to the reveal. + coEvery { + answerDataSource.submitAnswers(any(), any(), any(), any(), any(), any()) + } returns Unit + vm.retrySubmit() + advanceUntilIdle() + assertFalse(vm.uiState.value.submitFailed) + assertEquals(AppRoute.wheelComplete("sess1"), vm.uiState.value.navigateTo) + } } diff --git a/firestore.rules b/firestore.rules index d0d6e61e..8536da92 100644 --- a/firestore.rules +++ b/firestore.rules @@ -359,6 +359,19 @@ service cloud.firestore { // so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['status', 'completedAt', 'completedByUsers', 'joinedByUsers']) + // Defense-in-depth: a member may only ADD THEIR OWN uid to the progress arrays — never + // spoof the partner's join/completion, and never remove entries. (old ⊆ new ⊆ old ∪ {self}; + // `get(...,[])` tolerates docs created before these fields existed.) Compatible with the + // client writes: markUserComplete / markUserJoined / abandonSession all leave the arrays as + // old or old+self. + && request.resource.data.get('completedByUsers', []) + .hasAll(resource.data.get('completedByUsers', [])) + && request.resource.data.get('completedByUsers', []) + .hasOnly(resource.data.get('completedByUsers', []).concat([request.auth.uid])) + && request.resource.data.get('joinedByUsers', []) + .hasAll(resource.data.get('joinedByUsers', [])) + && request.resource.data.get('joinedByUsers', []) + .hasOnly(resource.data.get('joinedByUsers', []).concat([request.auth.uid])) // status is monotonic: stay the same, or transition active → completed (never revert). && (request.resource.data.status == resource.data.status || (resource.data.status == 'active' && request.resource.data.status == 'completed'));