diff --git a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt index 0942ad15..d82e3716 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt @@ -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 = 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) + ?.toMutableList() ?: mutableListOf() + if (userId !in completedBy) completedBy.add(userId) + + val updates = mutableMapOf("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 = runCatching { + val activeSession = getActiveSessionForCouple(coupleId) ?: return@runCatching + saveSession( + activeSession.copy( + completedAt = System.currentTimeMillis(), + status = "completed" + ) + ).getOrThrow() + } } diff --git a/app/src/main/java/app/closer/domain/model/QuestionSession.kt b/app/src/main/java/app/closer/domain/model/QuestionSession.kt index 0c49fbe2..8e58f4ce 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionSession.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionSession.kt @@ -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 = emptyList() ) diff --git a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt index 13a9a75f..42f0b8aa 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt @@ -11,4 +11,10 @@ interface QuestionSessionRepository { suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession? fun observeActiveSessionForCouple(coupleId: String): Flow 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 + + // Force-complete the active session (escape hatch for stuck/abandoned games). + suspend fun abandonSession(coupleId: String): Result } diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt index c723cfbc..5851537b 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -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 { + 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 = + sessionRepository.abandonSession(coupleId) + /** * Observe active session changes for a couple. */ diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 1e0c2d3b..4272bc33 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt index 1f4acf95..328b926d 100644 --- a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt +++ b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt @@ -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 = _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 + ) + } } } } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 2ce8e2ba..6f875d5d 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 15b2908b..50063475 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index 46aa16b1..b853c839 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -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 + ) + } } }