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,
"isPremium" to session.isPremium,
"status" to session.status,
"gameType" to session.gameType
"gameType" to session.gameType,
"completedByUsers" to session.completedByUsers
)
doc.set(data).await()
}
@ -152,4 +153,40 @@ class QuestionSessionRepositoryImpl @Inject constructor(
override suspend fun hasActiveSession(coupleId: String): Boolean =
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 isPremium: Boolean = false,
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?
fun observeActiveSessionForCouple(coupleId: String): Flow<QuestionSession?>
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 =
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.
*/

View File

@ -274,12 +274,12 @@ class DesireSyncViewModel @Inject constructor(
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() {
val cId = coupleId ?: return
val sId = sessionId ?: return
viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) }
gameSessionManager.markUserComplete(sId, cId)
.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() {
observeJob?.cancel()
sessionId = null
@ -363,7 +373,8 @@ fun DesireSyncScreen(
}
DesireSyncPhase.WAITING -> DSWaitingScreen(
partnerName = state.partnerName,
onBack = viewModel::quit
onBack = viewModel::quit,
onAbandon = viewModel::abandon
)
DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches,
@ -419,7 +430,7 @@ private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
}
@Composable
private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) {
private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon: () -> Unit = {}) {
Column(
modifier = Modifier
.fillMaxSize()
@ -455,6 +466,13 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -55,10 +56,13 @@ class WaitingForPartnerViewModel @Inject constructor(
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
init {
viewModelScope.launch {
val userId = gameSessionManager.currentUserId ?: return@launch
val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch
coupleId = couple.id
val partnerId = couple.userIds.firstOrNull { it != userId }
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() {
_uiState.update { it.copy(navigateTo = null) }
}
@ -154,6 +167,13 @@ fun WaitingForPartnerScreen(
) {
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 =
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() {
val cId = coupleId ?: return
val sId = sessionId ?: return
viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) }
gameSessionManager.markUserComplete(sId, cId)
.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() {
observeJob?.cancel()
sessionId = null
@ -419,7 +429,8 @@ fun HowWellScreen(
HowWellPhase.WAITING -> HowWellWaitingScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
onBack = viewModel::quit
onBack = viewModel::quit,
onAbandon = viewModel::abandon
)
HowWellPhase.COMPLETE -> CompleteScreen(
score = state.score,
@ -489,7 +500,7 @@ private fun PlayerIntroScreen(
}
@Composable
private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit) {
private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit, onAbandon: () -> Unit = {}) {
Column(
modifier = Modifier
.fillMaxSize()
@ -528,6 +539,13 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { 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
}
/** 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() {
val cId = coupleId ?: return
val sId = sessionId ?: return
viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sId, cId) }
gameSessionManager.markUserComplete(sId, cId)
.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() {
observeJob?.cancel()
sessionId = null
@ -362,7 +372,8 @@ fun ThisOrThatScreen(
)
TotPhase.WAITING -> WaitingForRevealScreen(
partnerName = state.partnerName,
onBack = viewModel::quit
onBack = viewModel::quit,
onAbandon = viewModel::abandon
)
TotPhase.REVEAL -> ThisOrThatReveal(
matched = state.matchedCount,
@ -694,7 +705,8 @@ private fun VersusBadge(
@Composable
private fun WaitingForRevealScreen(
partnerName: String,
onBack: () -> Unit
onBack: () -> Unit,
onAbandon: () -> Unit = {}
) {
Column(
modifier = Modifier
@ -748,6 +760,13 @@ private fun WaitingForRevealScreen(
) {
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.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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() {
val cId = coupleId ?: return
if (sessionId.isBlank()) return
viewModelScope.launch {
runCatching { gameSessionManager.finishGame(sessionId, cId) }
gameSessionManager.markUserComplete(sessionId, cId)
.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) }
companion object {
@ -191,7 +203,8 @@ fun WheelCompleteScreen(
)
WheelRevealPhase.WAITING -> WheelWaitingContent(
partnerName = state.partnerName,
onHome = { onNavigate(AppRoute.PLAY) }
onHome = { onNavigate(AppRoute.PLAY) },
onAbandon = viewModel::abandon
)
WheelRevealPhase.REVEAL -> WheelRevealContent(
categoryName = state.categoryName,
@ -207,7 +220,8 @@ fun WheelCompleteScreen(
@Composable
private fun WheelWaitingContent(
partnerName: String,
onHome: () -> Unit
onHome: () -> Unit,
onAbandon: () -> Unit = {}
) {
Column(
modifier = Modifier
@ -253,6 +267,13 @@ private fun WheelWaitingContent(
) {
Text("Back to Play")
}
TextButton(onClick = onAbandon) {
Text(
"End this game",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}