Compare commits

..

No commits in common. "d91aac5ffb943aae5aed31747328ec8f035bc842" and "a642e7e267f6a09e2b8c50f32c95a0153cd0b2d3" have entirely different histories.

8 changed files with 22 additions and 139 deletions

View File

@ -1,7 +1,7 @@
# Claude QA Coverage Matrix # Claude QA Coverage Matrix
> Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position. > Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position.
> **Round 4 (fix phase + re-QA) COMPLETE 2026-06-25, build `6f6f76a`:** E-003 + B-004 (P2) + A-OBS (P3) FIXED + verified live + committed. 0 open P0P2. Only E-OBS (P3, bg push channel) open — deferred (server change + user-gated functions deploy). Regression smoke clean (launch, This-or-That end-to-end + B-001 auto-close, chat `enc:v1:` at rest, C-NAV-001 back→launcher). > Round 1 in progress.
## Pass A — Couple-shared premium (states: neither / partner-only / self) ## Pass A — Couple-shared premium (states: neither / partner-only / self)
| Feature | neither→locked | partner→both unlock | self→unlock | Status | | Feature | neither→locked | partner→both unlock | self→unlock | Status |

View File

@ -154,19 +154,6 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
intermediate screen and interaction works (selections register, progress advances, both-answered gating, intermediate screen and interaction works (selections register, progress advances, both-answered gating,
reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play. reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play.
- The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both. - The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both.
- **VARY THE STYLE OF PLAY (don't just repeat the happy path):** across runs, deliberately exercise *different* ways a
real couple would play each game, because different inputs hit different code paths:
- **Different option/length/mood choices** — every round length (5/10/15), every mood/category, the "All topics"/
shuffle option, and **each distinct answer type** (A/B, Yes/No, True/False, 15 scale, multi-select, free-text).
- **Different answer *patterns* that change the result** — all-match vs all-mismatch vs partial; both-yes vs both-no
vs split (so reveals show "shared", "all private", "0 matches", "perfect/zero score" — verify each renders right).
- **Different turn orders / who-starts** — partner A starts vs partner B starts; the guesser opens before vs after
the subject finishes; both open simultaneously (race); one device much slower than the other.
- **Different exit/resume styles** — finish normally; quit mid-game; background mid-game then resume; cold-kill
mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games
back-to-back, and a *different* game type immediately after.
- **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next,
spamming the start button, tapping during the reveal animation. None should crash, duplicate, or desync.
- Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean. - Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean.
- Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**. - Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**.
- **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully. - **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully.
@ -188,21 +175,6 @@ Account); Paywall; Your Progress/Activity; Recovery.
opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game
from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today
AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug. AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug.
- **TAKE EVERY AVENUE (exhaustive nav fuzzing — actively hunt for nav bugs, don't just walk the happy path):** treat
navigation as something to *break*. On every screen, **tap every interactive element** — each button, card, row,
icon, chip, link, tab, header back-arrow, system back, and any "see all / history / edit / manage" affordance — and
follow where it goes. Then try the *combinations and sequences* a curious user hits:
- **Every order:** switch bottom tabs in many orders, mid-flow (open a game, jump to Messages, come back); enter a
deep screen then tab away then back; open A→B→C then back-back-back.
- **Rapid / repeated input:** double- and triple-tap navigation targets (especially "open game", "Play now",
"Create/Start session", notification taps) to surface double-push/duplicate-screen/stale-route bugs (cf. B-004).
- **Interrupt mid-navigation:** background/rotate/lock during a transition; tap a notification while already on that
screen, on a different screen, and while logged-out/unpaired; cold-start straight onto a deep link.
- **Dead-ends & traps:** from *every* screen confirm there's always a way out (back/close/home) — no screen that
strands the user, needs two backs, exits the app unexpectedly, loops, or lands blank. Re-check the asymmetric-game
waiting screens, replay/results screens, and paywall specifically.
- Log **every** wrong/duplicate/dead destination with the exact tap sequence to reproduce. Wrong/double-back or
dead-end = **P2** (P1 if it traps the user or loses their progress).
- **Back-stack / "double back":** from every entry point, **system back AND the in-app back arrow** return to the - **Back-stack / "double back":** from every entry point, **system back AND the in-app back arrow** return to the
correct previous screen — no dead-ends, no exiting the app unexpectedly, and **no screen that requires pressing correct previous screen — no dead-ends, no exiting the app unexpectedly, and **no screen that requires pressing
back twice** (duplicate/stacked destinations on the back stack = bug). Bottom-tab reselection and deep-link/ back twice** (duplicate/stacked destinations on the back stack = bug). Bottom-tab reselection and deep-link/
@ -231,31 +203,6 @@ Account); Paywall; Your Progress/Activity; Recovery.
couple — migration completes without exposing plaintext or losing/garbling old content, and a half-migrated couple couple — migration completes without exposing plaintext or losing/garbling old content, and a half-migrated couple
is safe (no mixed read failures, no downgrade). This is the riskiest data path for existing users. is safe (no mixed read failures, no downgrade). This is the riskiest data path for existing users.
### Pass G — Account creation, validation & fake-account abuse (MANDATORY — both the happy path AND the attacks)
Cover **every account-creation avenue a real user takes** and **every fake/abusive creation attempt an attacker would
try.** Use throwaway test accounts (sign-out → fresh sign-up; never `pm clear`). Report-first like every pass.
- **Real creation flows (happy path + validation):** sign-up (email/password and any social/anonymous path), profile
creation, and pairing — both **create-invite** and **accept-invite** sides. Verify field validation (invalid/empty
email, weak/short password, mismatched confirm, name length/emoji/unicode), the **error copy is friendly** (no raw
SDK/Firebase error leaking — cf. A-OBS), loading/disabled states, and that a brand-new unpaired account lands on the
correct "create or accept invite" home (not a broken/blank or paired view).
- **Duplicate / conflicting creation:** sign up with an **already-registered email** (clear "already in use", no crash,
offer sign-in); create a second account while one is signed in; re-run onboarding after completing it; accept an
invite while **already paired** (must be rejected cleanly); two devices accepting the **same invite** (single-use —
the second must fail gracefully).
- **Fake / malicious creation attempts (security — expect DENY, never crash or leak):** create an account that is
**NOT a member** of the test couple and attempt every cross-couple action (read messages/answers/dates/entitlements,
write to the couple, self-grant `premium`/`hasPremium`, join/hijack pairing with a guessed/expired/reused invite
code) — all must be **denied by rules** (this is the live execution of **D3**). Probe **invite-code abuse**: replay a
used code, use an expired code, brute-force/guess attempts (CSPRNG entropy + single-use + expiry must hold). Probe
**App Check**: a request without a valid token is rejected. Confirm a malformed/forged sign-up can't bypass profile
or membership requirements. **Any successful unauthorized create/read/write = P0.**
- **Account lifecycle around creation:** sign-out → sign-in (state restores, no stale couple); **delete account** then
re-create with the same email (clean slate, partner notified/unpaired); an unpaired/just-created account tapping a
stale notification or deep link is handled gracefully (no crash, sane landing).
- **Done = every creation avenue exercised** (happy + duplicate + malicious) with each attack **denied** and each happy
path validated end-to-end; findings filed with exact repro.
### Pass E — Notifications (every type delivers, deep-links, leaks nothing) ### Pass E — Notifications (every type delivers, deep-links, leaks nothing)
For each: trigger fires → delivered to the **right partner (never self)** → in **foreground/background/killed** For each: trigger fires → delivered to the **right partner (never self)** → in **foreground/background/killed**
correct channel + copy with **no private content****tap opens exactly the right item** (loaded, not generic Home/ correct channel + copy with **no private content****tap opens exactly the right item** (loaded, not generic Home/

View File

@ -1,6 +1,6 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 4 (fix phase + re-QA) COMPLETE — 2026-06-25, build HEAD `6f6f76a` on BOTH emulators. Fixed + verified LIVE the 3 tractable R3 findings: E-003 (game pushes now deep-link into the game, not the Play hub — tapped start-game push → This or That 1/5, joined), B-004 (WaitingForPartner now has a "Join the game" escape → How Well guess flow; deterministic repro fixed), A-OBS (paywall shows friendly copy, no raw "credentials issue"). Round-4 regression smoke clean: cold-launch no crash, This or That end-to-end → session auto-closes (B-001 holds), chat text `enc:v1:` at rest, C-NAV-001 back→launcher. ONLY REMAINING: E-OBS (P3) — backgrounded pushes use the FCM fallback channel; fix is server-side (set `android.notification.channel_id` across functions senders) + a functions deploy, which is USER-GATED → deferred for authorization. So: 0 open P0P2; 1 open P3 (E-OBS, deploy-gated). Baseline restored: both free, 0 active sessions.** > **RUN-STATE: Round 3 (full re-QA AF) COMPLETE — 2026-06-25, build `ce7fc2e`. RESULT: all 12 prior fixes RE-VERIFIED HOLDING LIVE; deeper play-as-user testing surfaced 5 NEW issues — 2×P2 (B-004 intermittent guesser-stuck on WaitingForPartner; E-003 game notifications deep-link to Play hub not the game), 3×P3 (A-OBS paywall raw error copy [env]; E-OBS bg pushes use fallback channel; C-OBS RESOLVED = debug menu IS BuildConfig.DEBUG-gated, not a bug). NO P0/P1, NO security/encryption findings (Pass D clean, E2EE holds at-rest + UI). NOT yet "flawless" (2 open P2 + Pass E has E-003) → NEXT: a FIX PHASE for E-003 (client routeFor + likely server payload gameType, mirrors B-002) + a deterministic B-004 repro before fixing, then Round 4 re-QA. Baseline restored: both free, 0 active sessions. E-003/B-004 are report-only (logged, not fixed mid-round per plan).**
> _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._ > _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._
> Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games. > Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games.
> **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed. > **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed.
@ -15,21 +15,21 @@ _(Prior games/notifications QA from 2026-06-24 was completed + verified; superse
--- ---
## Severity summary (current — after Round 4 fix phase + re-QA) ## Severity summary (current — after Round 3 re-QA)
| Severity | Open | Fixed (verified live) | | Severity | Open (new in R3) | Fixed (verified holding) |
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | 4 | | P1 | 0 | 4 |
| P2 | **0** | **6** | | P2 | **2** (B-004, E-003) | 4 |
| P3 | **1** (E-OBS — deploy-gated) | **5** | | P3 | **2** (A-OBS, E-OBS) | 4 |
**Round 4 result:** the 2 new P2 (E-003, B-004) + 1 new P3 (A-OBS) from R3 are **FIXED + verified live + committed**. **0 open P0P2.** Only E-OBS (P3) remains, deferred because its fix needs a user-gated functions deploy. Combined with R3 (all 12 earlier fixes hold; Pass D clean; E2EE at-rest + UI intact), the app has **no open crash/data/security/premium/nav-dead-end issues** — the one remaining item is notification-channel polish requiring a backend deploy. **Round 3 result:** all **12 prior fixes RE-VERIFIED HOLDING LIVE** (C-NAV-001, C-CC-001, A-001, A-003, B-001 across 4 game types, B-002, B-003, C-DS-001, D-001 + E-001/E-002 code + F-OBS indirect). **No P0/P1, no security/encryption findings** (Pass D clean; E2EE holds at-rest + in UI). Deeper play-as-user testing found **5 new issues: 2×P2 (B-004, E-003), 3×P3 (A-OBS, E-OBS, and C-OBS which RESOLVED to not-a-bug — debug menu is BuildConfig.DEBUG-gated)**. Not yet "flawless" (def: 0 open P0P2 + Passes D/E clean) — 2 open P2 remain → fix phase + Round 4 re-QA needed.
**R3/R4 issue dispositions:** **New R3 issues (report-only — logged, to fix next phase):**
- **E-003 (P2) — FIXED** `23c9923`: game pushes (`partner_started_game`/`partner_completed_part`) now route via `gameRouteForType(payload.gameType)` into the specific game (auto-joins the active session), not the Play hub. Server already sends `game_type`; client parses it in AppMessagingService + MainActivity. `game_results_ready` stays on the hub pending a server change to also send `game_session_id` (documented). **Verified live:** tapped start-game push → This or That 1/5 (joined). - **B-004 (P2, intermittent):** How Well guesser can get stuck on the generic WaitingForPartner screen during a rapid game-to-game transition (screen only exits on session end; needs deterministic repro before fix; escalate to P1 if deterministic).
- **B-004 (P2) — FIXED** `da7fc74`: `WaitingForPartnerScreen` now resolves the active session's game route and offers a primary **"Join the game"** action (every game is async/joinable), so the partner is never stuck. **Verified live** via deterministic repro: QA started How Well → Sam opened This or That → WaitingForPartner → "Join the game" → How Well guess intro. - **E-003 (P2):** game notifications (`partner_started_game`/`game_results_ready`/`partner_completed_part`) deep-link to the generic Play hub, not the specific game/results, despite "Tap to join!" (fix: extend HomeViewModel.gameRouteFor resolver to notification routing; likely needs server payload gameType).
- **A-OBS (P3) — FIXED** `6f6f76a`: paywall ErrorState no longer renders the raw billing/RC SDK message; shows friendly "We couldn't load subscription options right now…". **Verified live** (raw "credentials issue" gone). - **A-OBS (P3):** paywall plan-load shows raw "credentials issue" error (emulator has no RevenueCat sandbox; copy should be friendlier in prod).
- **E-OBS (P3) — OPEN, deferred:** backgrounded pushes use `fcm_fallback_notification_channel`, bypassing code-defined channels. Fix is server-side (set `android.notification.channel_id` on every FCM send across functions, or send data-only + build client-side) + a **functions deploy (user-gated)**. Cannot verify without deploying. - **E-OBS (P3):** backgrounded pushes use `fcm_fallback_notification_channel`, bypassing code-defined channels (CHANNEL_GAMES/chat) — server sends "notification" not data-only messages.
- **C-OBS (RESOLVED, not a bug):** Settings "Art preview/Paired home (debug)" entries ARE `BuildConfig.DEBUG`-gated (SettingsScreen.kt:469) — won't ship in release. - **C-OBS (RESOLVED, not a bug):** Settings "Art preview/Paired home (debug)" entries ARE `BuildConfig.DEBUG`-gated (SettingsScreen.kt:469) — won't ship in release.
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). **Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2).

View File

@ -129,7 +129,6 @@ class MainActivity : AppCompatActivity() {
questionId = intent.getStringExtra("question_id"), questionId = intent.getStringExtra("question_id"),
conversationId = intent.getStringExtra("conversation_id"), conversationId = intent.getStringExtra("conversation_id"),
gameSessionId = intent.getStringExtra("game_session_id"), gameSessionId = intent.getStringExtra("game_session_id"),
gameType = intent.getStringExtra("game_type"),
capsuleId = intent.getStringExtra("capsule_id"), capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"), challengeId = intent.getStringExtra("challenge_id"),
avatarUrl = intent.getStringExtra("sender_avatar_url") avatarUrl = intent.getStringExtra("sender_avatar_url")

View File

@ -78,7 +78,6 @@ class AppMessagingService : FirebaseMessagingService() {
questionId = message.data["question_id"], questionId = message.data["question_id"],
conversationId = message.data["conversation_id"], conversationId = message.data["conversation_id"],
gameSessionId = message.data["game_session_id"], gameSessionId = message.data["game_session_id"],
gameType = message.data["game_type"],
capsuleId = message.data["capsule_id"], capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"], challengeId = message.data["challenge_id"],
avatarUrl = message.data["sender_avatar_url"] avatarUrl = message.data["sender_avatar_url"]

View File

@ -9,7 +9,6 @@ import androidx.core.app.NotificationManagerCompat
import app.closer.MainActivity import app.closer.MainActivity
import app.closer.R import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.repository.AppSettings import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -268,16 +267,8 @@ enum class PartnerNotificationType(
fun routeFor(payload: PartnerNotificationPayload, coupleId: String = ""): String = when (this) { fun routeFor(payload: PartnerNotificationPayload, coupleId: String = ""): String = when (this) {
PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION
REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY
// Deep link straight into the waiting game (each game screen auto-joins the couple's active PARTNER_STARTED_GAME -> AppRoute.PLAY
// session on open), not the generic Play hub — the push says "Tap to join". Falls back to the PARTNER_COMPLETED_PART -> AppRoute.PLAY
// hub only if the server didn't send game_type. E-003.
PARTNER_STARTED_GAME -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY
PARTNER_COMPLETED_PART -> gameRouteForType(payload.gameType) ?: AppRoute.PLAY
// Results-ready means the session is COMPLETED, so the plain game route would show "start a
// new game" (getActiveSession returns only active sessions). The correct target is the
// per-session results/replay route — but that needs the server to also send game_session_id
// in the FCM data (currently it sends only game_type). Until that server change ships, route
// to the Play hub rather than a misleading setup screen. E-003 (results-ready follow-up).
GAME_RESULTS_READY -> AppRoute.PLAY GAME_RESULTS_READY -> AppRoute.PLAY
CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
@ -335,23 +326,8 @@ data class PartnerNotificationPayload(
val questionId: String? = null, val questionId: String? = null,
val conversationId: String? = null, val conversationId: String? = null,
val gameSessionId: String? = null, val gameSessionId: String? = null,
/** The active session's game type (e.g. "this_or_that"), used to deep link into the game. E-003. */
val gameType: String? = null,
val capsuleId: String? = null, val capsuleId: String? = null,
val challengeId: String? = null, val challengeId: String? = null,
/** Sender's avatar URL, used as the notification large icon when present. */ /** Sender's avatar URL, used as the notification large icon when present. */
val avatarUrl: String? = null val avatarUrl: String? = null
) )
/**
* Maps a session's [GameType] to the entry route that resumes/joins it. Each game screen detects the
* couple's active session on open and joins it, so deep linking here drops the partner straight into
* the waiting game (mirrors HomeViewModel.gameRouteFor for the Home "Play now" CTA). E-003.
*/
private fun gameRouteForType(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}

View File

@ -47,13 +47,7 @@ data class WaitingForPartnerUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val gameType: String = GameType.WHEEL, val gameType: String = GameType.WHEEL,
val partnerName: String = "Partner", val partnerName: String = "Partner",
val navigateTo: String? = null, val navigateTo: String? = null
/**
* Route into the partner's active game so this user can play their part, instead of being
* stuck on this "waiting" screen. Every current game is async (both play on their own device),
* so the partner who lands here can always join. Null only for an unknown game type. B-004.
*/
val joinRoute: String? = null
) )
@HiltViewModel @HiltViewModel
@ -87,8 +81,7 @@ class WaitingForPartnerViewModel @Inject constructor(
it.copy( it.copy(
isLoading = false, isLoading = false,
gameType = session.gameType, gameType = session.gameType,
partnerName = partnerName, partnerName = partnerName
joinRoute = gameTypeRoute(session.gameType)
) )
} }
} }
@ -180,30 +173,11 @@ fun WaitingForPartnerScreen(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
// Primary action: join the partner's game and play your part. Without this the Button(
// screen was a dead-end for async games the user could actually play (B-004). onClick = { onNavigate(AppRoute.PLAY) },
state.joinRoute?.let { route -> modifier = Modifier.fillMaxWidth()
Button( ) {
onClick = { onNavigate(route) }, Text("Back to Games")
modifier = Modifier.fillMaxWidth()
) {
Text("Join the game")
}
}
if (state.joinRoute != null) {
TextButton(
onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to Games")
}
} else {
Button(
onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to Games")
}
} }
TextButton(onClick = viewModel::abandonPartnerGame) { TextButton(onClick = viewModel::abandonPartnerGame) {
Text( Text(
@ -226,15 +200,6 @@ private fun gameTypeLabel(gameType: String): String = when (gameType) {
else -> gameType else -> gameType
} }
/** Entry route that joins the partner's active game (each game screen auto-joins on open). B-004. */
private fun gameTypeRoute(gameType: String): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
private fun gameTypeGlyphKey(gameType: String): String = when (gameType) { private fun gameTypeGlyphKey(gameType: String): String = when (gameType) {
GameType.WHEEL -> "play" GameType.WHEEL -> "play"
GameType.THIS_OR_THAT -> "question" GameType.THIS_OR_THAT -> "question"

View File

@ -119,10 +119,7 @@ fun PaywallScreen(
) )
uiState.error != null -> ErrorState( uiState.error != null -> ErrorState(
title = "Couldn't load plans", title = "Couldn't load plans",
// Always show friendly copy — the raw billing/RevenueCat SDK message (e.g. message = uiState.error ?: "Check your connection and tap to try again.",
// "There was a credentials issue. Check the underlying error…") is developer
// detail and must never surface to users. A-OBS.
message = "We couldn't load subscription options right now. Check your connection and tap to try again.",
retryLabel = "Try again", retryLabel = "Try again",
onRetry = { viewModel.retry() }, onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()