From 4ab79d34c4d0ae5df701baa0f119354ac01a28f0 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 7 Jul 2026 21:03:22 -0500 Subject: [PATCH] fix(ui,games): defect-polish batch C1 from P3/UX review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Challenges catalog: hide the 🔒 Premium badge once the couple has premium (A-003b; matches the Play hub's showPremiumBadge pattern). Verified live both directions under an authorized grant/revoke cycle. - Game banner lifecycle (BANNER-LIFE-001): entering a session's screen now consumes any banner pointing at it (GamePromptController.consumeForSession wired into ActiveGameSessionMonitor.enter), and activity from a DIFFERENT session may replace a stale persistent banner. Verified live: no banner on reveal; stale RESULTS banner replaced by a new session's prompt. - Waiting/join screen: says 'Your turn — {name} already played their part' for the non-starter once a first part landed (new partHasFinished mapped from partFinishNotifiedAt; completedByUsers only fills at reveal). Session observe mapping now also carries completedByUsers/joinedByUsers. - How Well results: matched-row colors are now a theme-aware container+content pair (dark mode was near-invisible: fixed pale-green container under onSurfaceVariant text). - Date Match: top card fully opaque — next card's text no longer bleeds through (was alpha 0.96). - functions: don't send 'X finished — see your results!' for abandoned/quit sessions (status flips to completed with empty completedByUsers; a real completion always has both uids). Found live when a quit triggered a false banner. Needs deploy (bundled with C4). Unit + functions suites green. Co-Authored-By: Claude Fable 5 --- .../QuestionSessionRepositoryImpl.kt | 9 +++++++- .../closer/domain/model/QuestionSession.kt | 6 +++++- .../notifications/ActiveGameSessionMonitor.kt | 6 +++++- .../notifications/GamePromptController.kt | 18 ++++++++++++++-- .../challenges/ConnectionChallengesScreen.kt | 5 ++++- .../app/closer/ui/dates/DateMatchScreen.kt | 3 ++- .../ui/games/WaitingForPartnerScreen.kt | 21 +++++++++++++++---- .../app/closer/ui/howwell/HowWellScreen.kt | 15 ++++++++----- functions/src/games/onGameSessionUpdate.ts | 7 ++++++- 9 files changed, 73 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt index 8d24b892..9739b5d0 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt @@ -228,7 +228,14 @@ class QuestionSessionRepositoryImpl @Inject constructor( partnerCompletedAt = doc.getLong("partnerCompletedAt"), isPremium = doc.getBoolean("isPremium") ?: false, status = doc.getString("status") ?: "active", - gameType = doc.getString("gameType") ?: GameType.WHEEL + gameType = doc.getString("gameType") ?: GameType.WHEEL, + // Needed by the waiting/join screen to tell "they're playing" + // apart from "they finished — your turn". + completedByUsers = (doc.get("completedByUsers") as? List<*>) + ?.filterIsInstance() ?: emptyList(), + joinedByUsers = (doc.get("joinedByUsers") as? List<*>) + ?.filterIsInstance() ?: emptyList(), + partHasFinished = doc.getTimestamp("partFinishNotifiedAt") != null ) } .onFailure { crashReporter.recordException(it) } diff --git a/app/src/main/java/app/closer/domain/model/QuestionSession.kt b/app/src/main/java/app/closer/domain/model/QuestionSession.kt index 2bfecb8c..6589c610 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionSession.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionSession.kt @@ -15,5 +15,9 @@ data class QuestionSession( val completedByUsers: List = emptyList(), /** Members other than the starter who have opened/joined this active session. Drives the * `partner_joined_game` notification to the starter. */ - val joinedByUsers: List = emptyList() + val joinedByUsers: List = emptyList(), + /** True once the server recorded a first finished part (partFinishNotifiedAt set) — i.e. + * ONE side has submitted their answers. Who finished isn't recorded; for the non-starter + * it is almost always the starter (they play at creation). */ + val partHasFinished: Boolean = false ) diff --git a/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt b/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt index 990db07a..09cde374 100644 --- a/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt +++ b/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt @@ -14,13 +14,17 @@ import javax.inject.Singleton * auto-displays the push and this monitor isn't consulted — which is the desired behaviour. */ @Singleton -class ActiveGameSessionMonitor @Inject constructor() { +class ActiveGameSessionMonitor @Inject constructor( + private val gamePromptController: GamePromptController +) { @Volatile var activeSessionId: String? = null private set fun enter(sessionId: String) { if (sessionId.isNotBlank()) activeSessionId = sessionId + // Being on the session's screen makes any banner about it redundant (BANNER-LIFE-001). + gamePromptController.consumeForSession(sessionId) } fun leave(sessionId: String) { diff --git a/app/src/main/java/app/closer/notifications/GamePromptController.kt b/app/src/main/java/app/closer/notifications/GamePromptController.kt index 315de9ff..2b6246cd 100644 --- a/app/src/main/java/app/closer/notifications/GamePromptController.kt +++ b/app/src/main/java/app/closer/notifications/GamePromptController.kt @@ -47,13 +47,27 @@ class GamePromptController @Inject constructor() { ) { if (coupleId.isBlank() || gameType.isBlank()) return // Don't let a transient presence nudge (started/joined) clobber a persistent banner - // (your turn / results) the user hasn't acted on yet. + // (your turn / results) the user hasn't acted on yet — unless the nudge is about a + // DIFFERENT session, in which case the old banner is stale (that game already moved on) + // and the new activity wins (BANNER-LIFE-001). val current = _prompt.value - if (current != null && current.kind.persistent && !kind.persistent) return + val sameSession = current?.gameSessionId != null && current.gameSessionId == gameSessionId + if (current != null && current.kind.persistent && !kind.persistent && sameSession) return _prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl) } fun dismiss() { _prompt.value = null } + + /** + * The user has reached this session's screen on their own (auto-flip reveal, replay, join) — + * any banner still pointing at it is redundant, so retire it (BANNER-LIFE-001). Banners for + * OTHER sessions are left alone. + */ + fun consumeForSession(sessionId: String) { + if (sessionId.isBlank()) return + val current = _prompt.value ?: return + if (current.gameSessionId == sessionId) _prompt.value = null + } } diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index 4286b250..cd11219b 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -445,7 +445,10 @@ private fun ChallengePickCard( modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) - if (challenge.isPremium) { + // Hide the 🔒 Premium badge once the couple has premium access — locked + // hints on owned features read as broken entitlements (A-003 pattern, + // matches the Play hub's showPremiumBadge = !hasPremium). + if (challenge.isPremium && !hasPremium) { Surface( shape = RoundedCornerShape(999.dp), color = CloserPalette.Gold.copy(alpha = 0.15f) diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt index 9887bd62..233c4e35 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt @@ -341,7 +341,8 @@ private fun DateCard( modifier = modifier, shape = RoundedCornerShape(32.dp), colors = CardDefaults.cardColors( - containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 0.96f) + // Top card must be fully opaque or the next card's text bleeds through it. + containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 1f) ), elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp) ) { diff --git a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt index b6307d1a..efacd0a5 100644 --- a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt +++ b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt @@ -59,7 +59,9 @@ data class WaitingForPartnerUiState( * stuck on this "waiting" screen. Every current game is async (both play on their own device), * so the partner who lands here can always join. Null only for an unknown game type. B-004. */ - val joinRoute: String? = null + val joinRoute: String? = null, + /** True when the partner already submitted their part — this user isn't waiting, it's their turn. */ + val partnerFinished: Boolean = false ) @HiltViewModel @@ -94,7 +96,11 @@ class WaitingForPartnerViewModel @Inject constructor( isLoading = false, gameType = session.gameType, partnerName = partnerName, - joinRoute = gameTypeRoute(session.gameType) + joinRoute = gameTypeRoute(session.gameType), + // "Your turn" only for the NON-starter once a first part landed — + // the starter plays at creation, so for the joiner that part is the + // partner's. (completedByUsers only fills at reveal, too late here.) + partnerFinished = session.partHasFinished && userId != session.startedByUserId ) } } @@ -176,14 +182,21 @@ fun WaitingForPartnerScreen( } Text( - text = "Waiting for ${state.partnerName}", + // "Waiting for X" is wrong once X already played — then it's THIS + // user's move and the screen should say so. + text = if (state.partnerFinished) "Your turn" + else "Waiting for ${state.partnerName}", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 24.dp), textAlign = TextAlign.Center ) Text( - text = "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game.", + text = if (state.partnerFinished) { + "${state.partnerName} already played their part of ${gameTypeLabel(state.gameType)} — jump in to see how you compare." + } else { + "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game." + }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp), diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index feb86c4f..560e2170 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -1014,14 +1014,19 @@ private fun HowWellScoreRing( @Composable private fun BreakdownRow(result: HowWellResult) { - val matchColor = Color(0xFF2E7D32) - val closeColor = Color(0xFFF57F17) - val missColor = Color(0xFFC62828) + // Match-row colors come as a container+content PAIR per theme: the old fixed pale-green + // container kept theme-driven onSurfaceVariant text, which is near-invisible in dark mode. + val dark = androidx.compose.foundation.isSystemInDarkTheme() + val matchColor = if (dark) Color(0xFF81C995) else Color(0xFF2E7D32) + val closeColor = if (dark) Color(0xFFF6C453) else Color(0xFFF57F17) + val missColor = if (dark) Color(0xFFE58A82) else Color(0xFFC62828) + val matchContainer = if (dark) Color(0xFF223A2A) else Color(0xFFE8F5E9) + val questionColor = if (result.isMatch && dark) Color(0xFFB9CBBE) else MaterialTheme.colorScheme.onSurfaceVariant Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( - containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface + containerColor = if (result.isMatch) matchContainer else MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(2.dp) ) { @@ -1039,7 +1044,7 @@ private fun BreakdownRow(result: HowWellResult) { Text( text = result.question.text, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = questionColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index fc69b6ae..8426581b 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -134,7 +134,12 @@ export const onGameSessionUpdate = functions.firestore } // ── Session completed (reveal ready for both) ──────────────────────── - if (status === 'completed' && !currentData.finishNotifiedAt) { + // Only a REAL completion has results: the async flow marks each user complete at reveal, so + // status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session + // also lands on 'completed' (the abandon write) but with an empty list — pushing + // "finished — see your results!" for those was a false notification into an empty reveal. + const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : [] + if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) { const claimed = await db.runTransaction(async (tx) => { const fresh = await tx.get(sessionRef) const d = fresh.data()