fix(ui,games): defect-polish batch C1 from P3/UX review

- 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 21:03:22 -05:00
parent d554483ef2
commit 4ab79d34c4
9 changed files with 73 additions and 17 deletions

View File

@ -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<String>() ?: emptyList(),
joinedByUsers = (doc.get("joinedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
partHasFinished = doc.getTimestamp("partFinishNotifiedAt") != null
)
}
.onFailure { crashReporter.recordException(it) }

View File

@ -15,5 +15,9 @@ data class QuestionSession(
val completedByUsers: List<String> = 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<String> = emptyList()
val joinedByUsers: List<String> = emptyList(),
/** True once the server recorded a first finished part (partFinishNotifiedAt set) i.e.
* ONE side has submitted their answers. Who finished isn't recorded; for the non-starter
* it is almost always the starter (they play at creation). */
val partHasFinished: Boolean = false
)

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
) {

View File

@ -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),

View File

@ -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
)

View File

@ -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()