feat(games): nudge + per-game copy + score a11y batch C4 from UX review

- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
  partner is still playing, not once it's your turn) reusing the generic
  thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
  Toast on result incl. friendly rate-limit copy. Verified live: nudge →
  partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
  ('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
  for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
  Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
  ('You matched on N of M') and the How Well score ring Canvas ('You guessed N
  of M correctly') — both were split/Canvas visuals invisible to TalkBack.

Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 21:50:39 -05:00
parent 8e8223ca97
commit fda53175a9
5 changed files with 97 additions and 7 deletions

View File

@ -149,10 +149,24 @@ private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
return when (prompt.kind) { return when (prompt.kind) {
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join") GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View") GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play") GamePromptKind.YOUR_TURN -> PromptStyle(
"$name played their part",
when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL -> "Your turn — guess their answers"
app.closer.domain.model.GameType.DESIRE_SYNC -> "Your turn — only mutual yeses ever show"
app.closer.domain.model.GameType.THIS_OR_THAT -> "Your turn — see where you line up"
else -> "Your turn — reveal how you line up"
},
"Play"
)
GamePromptKind.RESULTS -> PromptStyle( GamePromptKind.RESULTS -> PromptStyle(
"You both finished", "You both finished",
if (resolved != null) "See how you and $resolved compare" else "See how you both compare", when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL ->
if (resolved != null) "See how well you know $resolved" else "See how well you know each other"
app.closer.domain.model.GameType.DESIRE_SYNC -> "See what you both said yes to"
else -> if (resolved != null) "See how you and $resolved compare" else "See how you both compare"
},
"View" "View"
) )
} }

View File

@ -1,5 +1,11 @@
package app.closer.ui.games package app.closer.ui.games
import androidx.compose.ui.platform.LocalContext
import android.widget.Toast
import android.util.Log
import kotlinx.coroutines.tasks.await
import com.google.firebase.functions.FirebaseFunctionsException
import com.google.firebase.functions.FirebaseFunctions
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -16,6 +22,7 @@ import androidx.compose.material3.Button
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -61,12 +68,15 @@ data class WaitingForPartnerUiState(
*/ */
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. */ /** True when the partner already submitted their part — this user isn't waiting, it's their turn. */
val partnerFinished: Boolean = false val partnerFinished: Boolean = false,
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null
) )
@HiltViewModel @HiltViewModel
class WaitingForPartnerViewModel @Inject constructor( class WaitingForPartnerViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager private val gameSessionManager: GameSessionManager,
private val functions: FirebaseFunctions
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(WaitingForPartnerUiState()) private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow() val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
@ -108,6 +118,32 @@ class WaitingForPartnerViewModel @Inject constructor(
} }
} }
/**
* Nudge the partner while waiting on them reuses the generic thinking-of-you callable
* (10/day, quiet-hours-aware server-side). Fails gracefully to a one-shot message.
*/
fun sendNudge() {
if (_uiState.value.isSendingNudge) return
_uiState.update { it.copy(isSendingNudge = true) }
viewModelScope.launch {
val message = runCatching {
functions.getHttpsCallable("sendThinkingOfYouCallable").call().await()
}.fold(
onSuccess = { "Sent 💜" },
onFailure = { e ->
Log.w("WaitingForPartner", "nudge failed", e)
if ((e as? FirebaseFunctionsException)?.code ==
FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED
) "You've nudged a few times — give it a moment 💜"
else "Couldn't send right now. Try again."
}
)
_uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) }
}
}
fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) }
/** Force-end the partner's active session so this user can start a new game. */ /** Force-end the partner's active session so this user can start a new game. */
fun abandonPartnerGame() { fun abandonPartnerGame() {
val cId = coupleId ?: return val cId = coupleId ?: return
@ -138,6 +174,14 @@ fun WaitingForPartnerScreen(
} }
} }
val context = LocalContext.current
LaunchedEffect(state.nudgeResult) {
state.nudgeResult?.let {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
viewModel.consumeNudgeResult()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -233,6 +277,15 @@ fun WaitingForPartnerScreen(
Text("Back to Games") Text("Back to Games")
} }
} }
if (!state.partnerFinished) {
OutlinedButton(
onClick = viewModel::sendNudge,
enabled = !state.isSendingNudge,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isSendingNudge) "Sending…" else "Send a little nudge 💜")
}
}
TextButton(onClick = viewModel::abandonPartnerGame) { TextButton(onClick = viewModel::abandonPartnerGame) {
Text( Text(
"End their game", "End their game",

View File

@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -1018,7 +1020,9 @@ private fun HowWellScoreRing(
) { ) {
val progress = if (total == 0) 0f else score.toFloat() / total val progress = if (total == 0) 0f else score.toFloat() / total
Box( Box(
modifier = modifier, modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "You guessed $score of $total correctly"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {

View File

@ -51,6 +51,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay import app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -1139,7 +1141,11 @@ private fun ThisOrThatReveal(
private fun MatchScoreBadge(matched: Int, total: Int) { private fun MatchScoreBadge(matched: Int, total: Int) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
Surface( Surface(
modifier = Modifier.size(116.dp), modifier = Modifier
.size(116.dp)
.semantics(mergeDescendants = true) {
contentDescription = "You matched on $matched of $total"
},
shape = CircleShape, shape = CircleShape,
color = cs.primaryContainer, color = cs.primaryContainer,
shadowElevation = 8.dp shadowElevation = 8.dp

View File

@ -228,11 +228,24 @@ export const onGamePartFinished = functions.firestore
await notifyPartner( await notifyPartner(
db, messaging, recipient, finisherName, gameType, db, messaging, recipient, finisherName, gameType,
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, 'partner_completed_part', yourTurnBody(gameType), coupleId,
finisherAvatar, sessionId finisherAvatar, sessionId
) )
}) })
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType: string): string {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers'
case 'desire_sync': return 'Your turn — only mutual yeses ever show'
case 'this_or_that': return 'Your turn — see where you line up'
default: return 'Your turn — reveal how you line up'
}
}
/** /**
* Send notification to partner via FCM and write to notification_queue. * Send notification to partner via FCM and write to notification_queue.
*/ */