feat: add partner-waiting flow across all game screens with QuestionSession model updates
This commit is contained in:
parent
733c0967c2
commit
3ba6c659dd
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue