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:
parent
8e8223ca97
commit
fda53175a9
|
|
@ -149,10 +149,24 @@ private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
|
|||
return when (prompt.kind) {
|
||||
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
|
||||
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(
|
||||
"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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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 androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -16,6 +22,7 @@ import androidx.compose.material3.Button
|
|||
import app.closer.ui.components.CloserHeartLoader
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -61,12 +68,15 @@ data class WaitingForPartnerUiState(
|
|||
*/
|
||||
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
|
||||
val partnerFinished: Boolean = false,
|
||||
val isSendingNudge: Boolean = false,
|
||||
val nudgeResult: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class WaitingForPartnerViewModel @Inject constructor(
|
||||
private val gameSessionManager: GameSessionManager
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val functions: FirebaseFunctions
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
|
||||
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. */
|
||||
fun abandonPartnerGame() {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -233,6 +277,15 @@ fun WaitingForPartnerScreen(
|
|||
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) {
|
||||
Text(
|
||||
"End their game",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
|
|
@ -1018,7 +1020,9 @@ private fun HowWellScoreRing(
|
|||
) {
|
||||
val progress = if (total == 0) 0f else score.toFloat() / total
|
||||
Box(
|
||||
modifier = modifier,
|
||||
modifier = modifier.semantics(mergeDescendants = true) {
|
||||
contentDescription = "You guessed $score of $total correctly"
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -1139,7 +1141,11 @@ private fun ThisOrThatReveal(
|
|||
private fun MatchScoreBadge(matched: Int, total: Int) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier = Modifier.size(116.dp),
|
||||
modifier = Modifier
|
||||
.size(116.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "You matched on $matched of $total"
|
||||
},
|
||||
shape = CircleShape,
|
||||
color = cs.primaryContainer,
|
||||
shadowElevation = 8.dp
|
||||
|
|
|
|||
|
|
@ -228,11 +228,24 @@ export const onGamePartFinished = functions.firestore
|
|||
|
||||
await notifyPartner(
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue