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) {
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue