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"),
|
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
|
||||||
isPremium = doc.getBoolean("isPremium") ?: false,
|
isPremium = doc.getBoolean("isPremium") ?: false,
|
||||||
status = doc.getString("status") ?: "active",
|
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) }
|
.onFailure { crashReporter.recordException(it) }
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,9 @@ data class QuestionSession(
|
||||||
val completedByUsers: List<String> = emptyList(),
|
val completedByUsers: List<String> = emptyList(),
|
||||||
/** Members other than the starter who have opened/joined this active session. Drives the
|
/** Members other than the starter who have opened/joined this active session. Drives the
|
||||||
* `partner_joined_game` notification to the starter. */
|
* `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.
|
* auto-displays the push and this monitor isn't consulted — which is the desired behaviour.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ActiveGameSessionMonitor @Inject constructor() {
|
class ActiveGameSessionMonitor @Inject constructor(
|
||||||
|
private val gamePromptController: GamePromptController
|
||||||
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
var activeSessionId: String? = null
|
var activeSessionId: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun enter(sessionId: String) {
|
fun enter(sessionId: String) {
|
||||||
if (sessionId.isNotBlank()) activeSessionId = sessionId
|
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) {
|
fun leave(sessionId: String) {
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,27 @@ class GamePromptController @Inject constructor() {
|
||||||
) {
|
) {
|
||||||
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
|
// 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
|
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)
|
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
_prompt.value = null
|
_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)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
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(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = CloserPalette.Gold.copy(alpha = 0.15f)
|
color = CloserPalette.Gold.copy(alpha = 0.15f)
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,8 @@ private fun DateCard(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(32.dp),
|
shape = RoundedCornerShape(32.dp),
|
||||||
colors = CardDefaults.cardColors(
|
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)
|
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),
|
* 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.
|
* 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
|
@HiltViewModel
|
||||||
|
|
@ -94,7 +96,11 @@ class WaitingForPartnerViewModel @Inject constructor(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
gameType = session.gameType,
|
gameType = session.gameType,
|
||||||
partnerName = partnerName,
|
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(
|
||||||
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,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
|
|
||||||
|
|
@ -1014,14 +1014,19 @@ private fun HowWellScoreRing(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BreakdownRow(result: HowWellResult) {
|
private fun BreakdownRow(result: HowWellResult) {
|
||||||
val matchColor = Color(0xFF2E7D32)
|
// Match-row colors come as a container+content PAIR per theme: the old fixed pale-green
|
||||||
val closeColor = Color(0xFFF57F17)
|
// container kept theme-driven onSurfaceVariant text, which is near-invisible in dark mode.
|
||||||
val missColor = Color(0xFFC62828)
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = CardDefaults.cardColors(
|
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)
|
elevation = CardDefaults.cardElevation(2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1039,7 +1044,7 @@ private fun BreakdownRow(result: HowWellResult) {
|
||||||
Text(
|
Text(
|
||||||
text = result.question.text,
|
text = result.question.text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = questionColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,12 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session completed (reveal ready for both) ────────────────────────
|
// ── 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 claimed = await db.runTransaction(async (tx) => {
|
||||||
const fresh = await tx.get(sessionRef)
|
const fresh = await tx.get(sessionRef)
|
||||||
const d = fresh.data()
|
const d = fresh.data()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue