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:
parent
d554483ef2
commit
4ab79d34c4
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue