feat(games): GamePromptBanner UI + MessageBubbleOverlay polish, wheel bounce fix, GameCopy strings, QA coverage updates
This commit is contained in:
parent
f51a55743c
commit
b2dc96ca04
|
|
@ -1,7 +1,7 @@
|
||||||
# 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`.
|
||||||
> 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 `<bitmap>` `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 `<bitmap>` `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 ✓".
|
> **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 |
|
| 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) |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
|
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
|
||||||
|
|
||||||
## Run-state (current)
|
## 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** "<Name> 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) — 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** "<Name> 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) — 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`.
|
- **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`.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
android:name=".CloserApp"
|
android:name=".CloserApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ import javax.inject.Singleton
|
||||||
* whether it auto-dismisses. STARTED/JOINED are transient presence nudges; YOUR_TURN/RESULTS are
|
* 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.
|
* 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
|
* 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?
|
avatarUrl: String?
|
||||||
) {
|
) {
|
||||||
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
|
||||||
|
// (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)
|
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,17 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -79,16 +90,18 @@ fun GamePromptBanner(
|
||||||
viewModel: GamePromptViewModel = hiltViewModel()
|
viewModel: GamePromptViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val prompt by viewModel.prompt.collectAsState()
|
val prompt by viewModel.prompt.collectAsState()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
// Retain the last prompt so the slide-out animation still has data to render.
|
// Retain the last prompt so the slide-out animation still has data to render.
|
||||||
var last by remember { mutableStateOf<IncomingGamePrompt?>(null) }
|
var last by remember { mutableStateOf<IncomingGamePrompt?>(null) }
|
||||||
LaunchedEffect(prompt) { if (prompt != null) last = prompt }
|
LaunchedEffect(prompt) { if (prompt != null) last = prompt }
|
||||||
|
|
||||||
// Presence nudges (started/joined) auto-dismiss; act-on-it banners (your turn / results) persist
|
// A partner action is an emotional beat — give it a soft haptic on arrival. Then: presence nudges
|
||||||
// until tapped or dismissed so they're never missed.
|
// (started/joined) auto-dismiss; act-on-it banners (your turn / results) persist until tapped.
|
||||||
LaunchedEffect(prompt) {
|
LaunchedEffect(prompt) {
|
||||||
val p = prompt
|
val p = prompt ?: return@LaunchedEffect
|
||||||
if (p != null && !styleFor(p).persistent) {
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
if (!p.kind.persistent) {
|
||||||
delay(9000)
|
delay(9000)
|
||||||
viewModel.dismiss()
|
viewModel.dismiss()
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +110,14 @@ fun GamePromptBanner(
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = prompt != null,
|
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(),
|
exit = slideOutVertically { -it } + fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.TopCenter)
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
) {
|
) {
|
||||||
|
|
@ -114,18 +134,19 @@ fun GamePromptBanner(
|
||||||
private data class PromptStyle(
|
private data class PromptStyle(
|
||||||
val line1: String,
|
val line1: String,
|
||||||
val line2: String,
|
val line2: String,
|
||||||
val action: String,
|
val action: String
|
||||||
val persistent: Boolean
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
|
||||||
val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner"
|
val name = prompt.partnerName?.takeIf { it.isNotBlank() } ?: "Your partner"
|
||||||
val game = gameDisplayName(prompt.gameType)
|
val game = gameDisplayName(prompt.gameType)
|
||||||
return when (prompt.kind) {
|
return when (prompt.kind) {
|
||||||
GamePromptKind.STARTED -> PromptStyle("$name started a game", game, "Join", persistent = false)
|
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
|
||||||
GamePromptKind.JOINED -> PromptStyle("$name joined your game", game, "View", persistent = false)
|
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
|
||||||
GamePromptKind.YOUR_TURN -> PromptStyle("$name finished their part", "Your turn", "Play", persistent = true)
|
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play")
|
||||||
GamePromptKind.RESULTS -> PromptStyle("$name finished", "See your results", "View", persistent = true)
|
GamePromptKind.RESULTS -> PromptStyle("You both finished", "See how you and $name compare", "View")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +157,7 @@ private fun GamePromptCard(
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val style = styleFor(prompt)
|
val style = styleFor(prompt)
|
||||||
|
var dragUp by remember(prompt.kind, prompt.gameSessionId) { mutableStateOf(0f) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
|
|
@ -149,32 +171,55 @@ private fun GamePromptCard(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(20.dp))
|
.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),
|
.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Partner avatar (falls back to a play glyph).
|
// Partner avatar (falls back to a play glyph) + a live "here now" dot when they JOIN.
|
||||||
Box(
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.size(40.dp)
|
modifier = Modifier
|
||||||
.clip(CircleShape)
|
.size(40.dp)
|
||||||
.background(Color.White.copy(alpha = 0.18f))
|
.clip(CircleShape)
|
||||||
.border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape),
|
.background(Color.White.copy(alpha = 0.18f))
|
||||||
contentAlignment = Alignment.Center
|
.border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape),
|
||||||
) {
|
contentAlignment = Alignment.Center
|
||||||
val avatar = prompt.avatarUrl
|
) {
|
||||||
if (!avatar.isNullOrBlank()) {
|
val avatar = prompt.avatarUrl
|
||||||
AsyncImage(
|
if (!avatar.isNullOrBlank()) {
|
||||||
model = avatar,
|
AsyncImage(
|
||||||
contentDescription = null,
|
model = avatar,
|
||||||
contentScale = ContentScale.Crop,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(40.dp).clip(CircleShape)
|
contentScale = ContentScale.Crop,
|
||||||
)
|
modifier = Modifier.size(40.dp).clip(CircleShape)
|
||||||
} else {
|
)
|
||||||
Icon(
|
} else {
|
||||||
imageVector = CloserGlyphs.Play,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = CloserGlyphs.Play,
|
||||||
tint = Color.White,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(22.dp)
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -62,6 +65,12 @@ fun MessageBubbleOverlay(
|
||||||
val bubble by viewModel.bubble.collectAsState()
|
val bubble by viewModel.bubble.collectAsState()
|
||||||
val current = bubble ?: return
|
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 density = LocalDensity.current
|
||||||
val bubbleSize = 60.dp
|
val bubbleSize = 60.dp
|
||||||
val margin = 14.dp
|
val margin = 14.dp
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import app.closer.domain.model.SessionLength
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import app.closer.domain.repository.QuestionSessionRepository
|
import app.closer.domain.repository.QuestionSessionRepository
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
|
import app.closer.ui.games.GameCopy
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import app.closer.ui.components.BrandMessageRotator
|
import app.closer.ui.components.BrandMessageRotator
|
||||||
import app.closer.ui.components.StatusGlyph
|
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 +
|
// Tell the starter "your partner joined" (best-effort, off the critical path; idempotent +
|
||||||
// no-op for the starter server-side).
|
// no-op for the starter server-side).
|
||||||
if (cId != null) viewModelScope.launch {
|
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 &&
|
val alreadyAnswered = uid != null && cId != null &&
|
||||||
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
||||||
|
|
@ -294,7 +296,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
submitted = false
|
submitted = false
|
||||||
Log.w(TAG, "Could not submit answers", e)
|
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.
|
// The observer flips WAITING → REVEAL once the partner's answers land.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ import app.closer.data.remote.FirestoreHowWellDataSource
|
||||||
import app.closer.data.remote.HowWellAnswers
|
import app.closer.data.remote.HowWellAnswers
|
||||||
import app.closer.data.remote.HowWellRawAnswer
|
import app.closer.data.remote.HowWellRawAnswer
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
|
import app.closer.ui.games.GameCopy
|
||||||
import app.closer.ui.components.BrandMessageRotator
|
import app.closer.ui.components.BrandMessageRotator
|
||||||
import app.closer.ui.components.ResultGlyph
|
import app.closer.ui.components.ResultGlyph
|
||||||
import app.closer.ui.components.StatusGlyph
|
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 +
|
// Tell the starter "your partner joined" (best-effort, off the critical path; idempotent +
|
||||||
// no-op for the starter server-side).
|
// no-op for the starter server-side).
|
||||||
if (cId != null && uid != startedBy) viewModelScope.launch {
|
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 &&
|
val alreadyAnswered = cId != null &&
|
||||||
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
||||||
|
|
@ -321,7 +323,7 @@ class HowWellViewModel @Inject constructor(
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
submitted = false
|
submitted = false
|
||||||
Log.w(TAG, "Could not submit answers", e)
|
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.
|
// The observer flips WAITING → COMPLETE once the partner's answers land.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import app.closer.domain.repository.QuestionSessionRepository
|
import app.closer.domain.repository.QuestionSessionRepository
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
|
import app.closer.ui.games.GameCopy
|
||||||
import app.closer.ui.components.BrandMessageRotator
|
import app.closer.ui.components.BrandMessageRotator
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.Settings
|
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 +
|
// Tell the starter "your partner joined" (best-effort, off the critical path; idempotent +
|
||||||
// no-op for the starter server-side).
|
// no-op for the starter server-side).
|
||||||
if (cId != null) viewModelScope.launch {
|
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 &&
|
val alreadyAnswered = uid != null && cId != null &&
|
||||||
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
runCatching { dataSource.getAnswers(cId, existingSessionId)?.byUser?.get(uid)?.isNotEmpty() == true }
|
||||||
|
|
@ -347,7 +349,7 @@ class ThisOrThatViewModel @Inject constructor(
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
submitted = false
|
submitted = false
|
||||||
Log.w(TAG, "Could not submit answers", e)
|
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
|
// The observer flips WAITING → REVEAL once the partner's answers land
|
||||||
// (or right away, if they finished first).
|
// (or right away, if they finished first).
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,9 @@ fun WheelSessionScreen(
|
||||||
onWrittenTextChanged = viewModel::onWrittenTextChanged,
|
onWrittenTextChanged = viewModel::onWrittenTextChanged,
|
||||||
onNext = viewModel::next,
|
onNext = viewModel::next,
|
||||||
onSkip = viewModel::skip,
|
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,
|
onWrittenTextChanged: (String) -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onSkip: () -> Unit,
|
onSkip: () -> Unit,
|
||||||
onEnd: () -> Unit
|
onEnd: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onAbandon: () -> Unit
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -123,6 +127,14 @@ private fun WheelSessionContent(
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.submitFailed) {
|
||||||
|
SubmitErrorCard(
|
||||||
|
message = state.error ?: "Couldn't save your answers.",
|
||||||
|
onRetry = onRetry
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
EmptySessionCard()
|
EmptySessionCard()
|
||||||
return@Column
|
return@Column
|
||||||
|
|
@ -279,6 +291,18 @@ private fun WheelSessionContent(
|
||||||
Text("Finish now", color = Color(0xFF9B8AA6))
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun WheelSessionScreenPreview() {
|
fun WheelSessionScreenPreview() {
|
||||||
|
|
@ -542,6 +600,8 @@ fun WheelSessionScreenPreview() {
|
||||||
onWrittenTextChanged = {},
|
onWrittenTextChanged = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
onSkip = {},
|
onSkip = {},
|
||||||
onEnd = {}
|
onEnd = {},
|
||||||
|
onRetry = {},
|
||||||
|
onAbandon = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import app.closer.domain.model.ScaleAnswerConfigImpl
|
||||||
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
|
import app.closer.ui.games.GameCopy
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -39,6 +40,9 @@ data class WheelSessionUiState(
|
||||||
val categoryName: String = "",
|
val categoryName: String = "",
|
||||||
val navigateTo: String? = null,
|
val navigateTo: String? = null,
|
||||||
val isEmpty: Boolean = false,
|
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<String> = emptyList(),
|
val selectedOptionIds: List<String> = emptyList(),
|
||||||
val selectedScaleValue: Int = 3,
|
val selectedScaleValue: Int = 3,
|
||||||
val writtenText: String = ""
|
val writtenText: String = ""
|
||||||
|
|
@ -104,7 +108,8 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
// session (best-effort, off the critical path; idempotent + no-op for the starter).
|
// session (best-effort, off the critical path; idempotent + no-op for the starter).
|
||||||
if (session != null && uid != session.startedByUserId) {
|
if (session != null && uid != session.startedByUserId) {
|
||||||
viewModelScope.launch {
|
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
|
// 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)
|
WheelAnswerEntry(questionId = q.id, display = state.answers.getOrNull(i) ?: SKIPPED)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
val result = runCatching {
|
||||||
answerDataSource.submitAnswers(
|
answerDataSource.submitAnswers(
|
||||||
coupleId = cId,
|
coupleId = cId,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
|
@ -269,8 +274,39 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
questions = questionRefs,
|
questions = questionRefs,
|
||||||
answers = entries
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class WheelSessionViewModelTest {
|
||||||
coEvery { gameSessionManager.getActiveSession("couple1") } returns
|
coEvery { gameSessionManager.getActiveSession("couple1") } returns
|
||||||
QuestionSession(questionIds = listOf("q1", "q2"))
|
QuestionSession(questionIds = listOf("q1", "q2"))
|
||||||
coEvery { answerDataSource.getDoc("couple1", "sess1") } returns null
|
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("q1") } returns writtenQ
|
||||||
coEvery { repository.getQuestionById("q2") } returns choiceQ
|
coEvery { repository.getQuestionById("q2") } returns choiceQ
|
||||||
coEvery { repository.getCategoryById(any()) } returns null
|
coEvery { repository.getCategoryById(any()) } returns null
|
||||||
|
|
@ -157,4 +158,32 @@ class WheelSessionViewModelTest {
|
||||||
assertEquals(0, vm.uiState.value.unansweredCount)
|
assertEquals(0, vm.uiState.value.unansweredCount)
|
||||||
assertEquals(AppRoute.wheelComplete("sess1"), vm.uiState.value.navigateTo)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,19 @@ service cloud.firestore {
|
||||||
// so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.)
|
// so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.)
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['status', 'completedAt', 'completedByUsers', 'joinedByUsers'])
|
.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).
|
// status is monotonic: stay the same, or transition active → completed (never revert).
|
||||||
&& (request.resource.data.status == resource.data.status
|
&& (request.resource.data.status == resource.data.status
|
||||||
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'));
|
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue