feat: add partner-waiting flow across all game screens with QuestionSession model updates

This commit is contained in:
null 2026-06-19 01:14:04 -05:00
parent 733c0967c2
commit 3ba6c659dd
9 changed files with 175 additions and 17 deletions

View File

@ -43,7 +43,8 @@ class QuestionSessionRepositoryImpl @Inject constructor(
"partnerCompletedAt" to session.partnerCompletedAt, "partnerCompletedAt" to session.partnerCompletedAt,
"isPremium" to session.isPremium, "isPremium" to session.isPremium,
"status" to session.status, "status" to session.status,
"gameType" to session.gameType "gameType" to session.gameType,
"completedByUsers" to session.completedByUsers
) )
doc.set(data).await() doc.set(data).await()
} }
@ -152,4 +153,40 @@ class QuestionSessionRepositoryImpl @Inject constructor(
override suspend fun hasActiveSession(coupleId: String): Boolean = override suspend fun hasActiveSession(coupleId: String): Boolean =
getActiveSessionForCouple(coupleId) != null getActiveSessionForCouple(coupleId) != null
override suspend fun markUserComplete(
sessionId: String,
coupleId: String,
userId: String
): Result<Unit> = runCatching {
val docRef = firestore.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.SESSIONS)
.document(sessionId)
firestore.runTransaction { tx ->
val snap = tx.get(docRef)
@Suppress("UNCHECKED_CAST")
val completedBy = (snap.get("completedByUsers") as? List<String>)
?.toMutableList() ?: mutableListOf()
if (userId !in completedBy) completedBy.add(userId)
val updates = mutableMapOf<String, Any>("completedByUsers" to completedBy)
if (completedBy.size >= 2) {
updates["status"] = "completed"
updates["completedAt"] = System.currentTimeMillis()
}
tx.update(docRef, updates)
}.await()
}
override suspend fun abandonSession(coupleId: String): Result<Unit> = runCatching {
val activeSession = getActiveSessionForCouple(coupleId) ?: return@runCatching
saveSession(
activeSession.copy(
completedAt = System.currentTimeMillis(),
status = "completed"
)
).getOrThrow()
}
} }

View File

@ -11,5 +11,6 @@ data class QuestionSession(
val partnerCompletedAt: Long? = null, val partnerCompletedAt: Long? = null,
val isPremium: Boolean = false, val isPremium: Boolean = false,
val status: String = "active", val status: String = "active",
val gameType: String = "wheel" val gameType: String = "wheel",
val completedByUsers: List<String> = emptyList()
) )

View File

@ -11,4 +11,10 @@ interface QuestionSessionRepository {
suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession? suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession?
fun observeActiveSessionForCouple(coupleId: String): Flow<QuestionSession?> fun observeActiveSessionForCouple(coupleId: String): Flow<QuestionSession?>
suspend fun hasActiveSession(coupleId: String): Boolean suspend fun hasActiveSession(coupleId: String): Boolean
// Per-user completion: marks one user done; auto-completes when both users are recorded.
suspend fun markUserComplete(sessionId: String, coupleId: String, userId: String): Result<Unit>
// Force-complete the active session (escape hatch for stuck/abandoned games).
suspend fun abandonSession(coupleId: String): Result<Unit>
} }

View File

@ -149,6 +149,24 @@ class GameSessionManager @Inject constructor(
suspend fun hasActiveSession(coupleId: String): Boolean = suspend fun hasActiveSession(coupleId: String): Boolean =
sessionRepository.hasActiveSession(coupleId) sessionRepository.hasActiveSession(coupleId)
/**
* Mark the current user's side of the session as complete.
* The session is automatically finished once both users have marked done.
* Safe to call concurrently a Firestore transaction ensures idempotency.
*/
suspend fun markUserComplete(sessionId: String, coupleId: String): Result<Unit> {
val userId = authRepository.currentUserId
?: return Result.failure(Exception("Not signed in"))
return sessionRepository.markUserComplete(sessionId, coupleId, userId)
}
/**
* Force-finish the couple's active session without requiring both users to mark done.
* Use when one partner wants to release the lock (e.g. partner never played).
*/
suspend fun abandonSession(coupleId: String): Result<Unit> =
sessionRepository.abandonSession(coupleId)
/** /**
* Observe active session changes for a couple. * Observe active session changes for a couple.
*/ */

View File

@ -274,12 +274,12 @@ class DesireSyncViewModel @Inject constructor(
finishSession() finishSession()
} }
/** Mark the shared session completed (idempotent — fine if the partner already did). */ /** Both answered — mark this user done; session auto-completes once both sides recorded. */
private fun finishSession() { private fun finishSession() {
val cId = coupleId ?: return val cId = coupleId ?: return
val sId = sessionId ?: return val sId = sessionId ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) } gameSessionManager.markUserComplete(sId, cId)
.onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") } .onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") }
} }
} }
@ -293,6 +293,16 @@ class DesireSyncViewModel @Inject constructor(
} }
} }
/** Force-end the game from the waiting screen (partner never played). */
fun abandon() {
val cId = coupleId ?: return
viewModelScope.launch {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "abandon no-op: ${it.message}") }
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun restart() { fun restart() {
observeJob?.cancel() observeJob?.cancel()
sessionId = null sessionId = null
@ -363,7 +373,8 @@ fun DesireSyncScreen(
} }
DesireSyncPhase.WAITING -> DSWaitingScreen( DesireSyncPhase.WAITING -> DSWaitingScreen(
partnerName = state.partnerName, partnerName = state.partnerName,
onBack = viewModel::quit onBack = viewModel::quit,
onAbandon = viewModel::abandon
) )
DesireSyncPhase.REVEAL -> DSRevealScreen( DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches, matches = state.matches,
@ -419,7 +430,7 @@ private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
} }
@Composable @Composable
private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) { private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon: () -> Unit = {}) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -455,6 +466,13 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") } ) { Text("Back to Play") }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -15,6 +15,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -55,10 +56,13 @@ class WaitingForPartnerViewModel @Inject constructor(
private val _uiState = MutableStateFlow(WaitingForPartnerUiState()) private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow() val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
val userId = gameSessionManager.currentUserId ?: return@launch val userId = gameSessionManager.currentUserId ?: return@launch
val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch
coupleId = couple.id
val partnerId = couple.userIds.firstOrNull { it != userId } val partnerId = couple.userIds.firstOrNull { it != userId }
val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName ?: "Partner" val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName ?: "Partner"
@ -79,6 +83,15 @@ class WaitingForPartnerViewModel @Inject constructor(
} }
} }
/** Force-end the partner's active session so this user can start a new game. */
fun abandonPartnerGame() {
val cId = coupleId ?: return
viewModelScope.launch {
gameSessionManager.abandonSession(cId)
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun onNavigated() { fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }
@ -154,6 +167,13 @@ fun WaitingForPartnerScreen(
) { ) {
Text("Back to Games") Text("Back to Games")
} }
TextButton(onClick = viewModel::abandonPartnerGame) {
Text(
"End their game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }

View File

@ -323,12 +323,12 @@ class HowWellViewModel @Inject constructor(
private fun HowWellRawAnswer?.toAnswer(): HowWellAnswer = private fun HowWellRawAnswer?.toAnswer(): HowWellAnswer =
HowWellAnswer(this?.optionId, this?.scale) HowWellAnswer(this?.optionId, this?.scale)
/** Mark the shared session completed (idempotent — fine if the partner already did). */ /** Both answered — mark this user done; session auto-completes once both sides recorded. */
private fun finishSession() { private fun finishSession() {
val cId = coupleId ?: return val cId = coupleId ?: return
val sId = sessionId ?: return val sId = sessionId ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) } gameSessionManager.markUserComplete(sId, cId)
.onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") } .onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") }
} }
} }
@ -340,6 +340,16 @@ class HowWellViewModel @Inject constructor(
} }
} }
/** Force-end the game from the waiting screen (partner never played). */
fun abandon() {
val cId = coupleId ?: return
viewModelScope.launch {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "abandon no-op: ${it.message}") }
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun restart() { fun restart() {
observeJob?.cancel() observeJob?.cancel()
sessionId = null sessionId = null
@ -419,7 +429,8 @@ fun HowWellScreen(
HowWellPhase.WAITING -> HowWellWaitingScreen( HowWellPhase.WAITING -> HowWellWaitingScreen(
amSubject = state.amSubject, amSubject = state.amSubject,
partnerName = state.partnerName, partnerName = state.partnerName,
onBack = viewModel::quit onBack = viewModel::quit,
onAbandon = viewModel::abandon
) )
HowWellPhase.COMPLETE -> CompleteScreen( HowWellPhase.COMPLETE -> CompleteScreen(
score = state.score, score = state.score,
@ -489,7 +500,7 @@ private fun PlayerIntroScreen(
} }
@Composable @Composable
private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit) { private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit, onAbandon: () -> Unit = {}) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -528,6 +539,13 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") } ) { Text("Back to Play") }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -288,12 +288,12 @@ class ThisOrThatViewModel @Inject constructor(
else -> optionId else -> optionId
} }
/** Mark the shared session completed (idempotent — fine if the partner already did). */ /** Both answered — mark this user done; session auto-completes once both sides recorded. */
private fun finishSession() { private fun finishSession() {
val cId = coupleId ?: return val cId = coupleId ?: return
val sId = sessionId ?: return val sId = sessionId ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) } gameSessionManager.markUserComplete(sId, cId)
.onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") } .onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") }
} }
} }
@ -307,6 +307,16 @@ class ThisOrThatViewModel @Inject constructor(
} }
} }
/** Force-end the game from the waiting screen (partner never played). */
fun abandon() {
val cId = coupleId ?: return
viewModelScope.launch {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "abandon no-op: ${it.message}") }
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun restart() { fun restart() {
observeJob?.cancel() observeJob?.cancel()
sessionId = null sessionId = null
@ -362,7 +372,8 @@ fun ThisOrThatScreen(
) )
TotPhase.WAITING -> WaitingForRevealScreen( TotPhase.WAITING -> WaitingForRevealScreen(
partnerName = state.partnerName, partnerName = state.partnerName,
onBack = viewModel::quit onBack = viewModel::quit,
onAbandon = viewModel::abandon
) )
TotPhase.REVEAL -> ThisOrThatReveal( TotPhase.REVEAL -> ThisOrThatReveal(
matched = state.matchedCount, matched = state.matchedCount,
@ -694,7 +705,8 @@ private fun VersusBadge(
@Composable @Composable
private fun WaitingForRevealScreen( private fun WaitingForRevealScreen(
partnerName: String, partnerName: String,
onBack: () -> Unit onBack: () -> Unit,
onAbandon: () -> Unit = {}
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -748,6 +760,13 @@ private fun WaitingForRevealScreen(
) { ) {
Text("Back to Play") Text("Back to Play")
} }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -30,6 +30,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -146,15 +147,26 @@ class WheelCompleteViewModel @Inject constructor(
} }
} }
/** Both answered — mark this user done; session auto-completes once both sides recorded. */
private fun finishSession() { private fun finishSession() {
val cId = coupleId ?: return val cId = coupleId ?: return
if (sessionId.isBlank()) return if (sessionId.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sessionId, cId) } gameSessionManager.markUserComplete(sessionId, cId)
.onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") } .onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") }
} }
} }
/** Force-end the game from the waiting screen (partner never played). */
fun abandon() {
val cId = coupleId ?: return
viewModelScope.launch {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "abandon no-op: ${it.message}") }
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
companion object { companion object {
@ -191,7 +203,8 @@ fun WheelCompleteScreen(
) )
WheelRevealPhase.WAITING -> WheelWaitingContent( WheelRevealPhase.WAITING -> WheelWaitingContent(
partnerName = state.partnerName, partnerName = state.partnerName,
onHome = { onNavigate(AppRoute.PLAY) } onHome = { onNavigate(AppRoute.PLAY) },
onAbandon = viewModel::abandon
) )
WheelRevealPhase.REVEAL -> WheelRevealContent( WheelRevealPhase.REVEAL -> WheelRevealContent(
categoryName = state.categoryName, categoryName = state.categoryName,
@ -207,7 +220,8 @@ fun WheelCompleteScreen(
@Composable @Composable
private fun WheelWaitingContent( private fun WheelWaitingContent(
partnerName: String, partnerName: String,
onHome: () -> Unit onHome: () -> Unit,
onAbandon: () -> Unit = {}
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -253,6 +267,13 @@ private fun WheelWaitingContent(
) { ) {
Text("Back to Play") Text("Back to Play")
} }
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }