fix: integrate game session lifecycle into DesireSync, HowWell, and ThisOrThat

This commit is contained in:
null 2026-06-18 02:29:14 -05:00
parent 2b8e05b29b
commit 4134c4570d
4 changed files with 151 additions and 13 deletions

View File

@ -10,6 +10,15 @@ import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Identifies a started game session plus the couple it belongs to, so a caller
* can finish exactly that session later without re-resolving the couple.
*/
data class GameHandle(
val sessionId: String,
val coupleId: String
)
/** /**
* Manages game session lifecycle with partner notifications. * Manages game session lifecycle with partner notifications.
* Enforces "one active game per couple" rule. * Enforces "one active game per couple" rule.
@ -66,6 +75,33 @@ class GameSessionManager @Inject constructor(
return saveResult.map { session.id } return saveResult.map { session.id }
} }
/**
* Start a game for the currently signed-in user, resolving their couple.
* Returns a [GameHandle] on success. Fails if the user is solo or a session
* is already active (the failure message carries partner/game context).
*/
suspend fun startGameForCurrentUser(
gameType: String,
categoryId: String? = null,
questionIds: List<String>? = null
): Result<GameHandle> {
val userId = authRepository.currentUserId
?: return Result.failure(Exception("Not signed in"))
val couple = coupleRepository.getCoupleForUser(userId)
?: return Result.failure(Exception("User is not in a couple"))
return startGame(userId, gameType, categoryId, questionIds)
.map { sessionId -> GameHandle(sessionId, couple.id) }
}
/**
* Finish the session referenced by [handle] for the current user.
*/
suspend fun finishGameForCurrentUser(handle: GameHandle): Result<Unit> {
val userId = authRepository.currentUserId
?: return Result.failure(Exception("Not signed in"))
return finishGame(handle.sessionId, handle.coupleId, userId)
}
/** /**
* Finish the current session for a user. * Finish the current session for a user.
* Marks the session as completed by this user. * Marks the session as completed by this user.
@ -89,7 +125,10 @@ class GameSessionManager @Inject constructor(
status = "completed" status = "completed"
) )
sessionRepository.saveSession(updatedSession) // Propagate a failed write: saveSession returns Result.failure rather than
// throwing, so without getOrThrow() the outer runCatching would report success
// and leave the session stuck "active" (locking the couple out of new games).
sessionRepository.saveSession(updatedSession).getOrThrow()
} }
/** /**

View File

@ -55,6 +55,7 @@ import app.closer.core.navigation.AppRoute
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
@ -123,6 +124,9 @@ class DesireSyncViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DesireSyncUiState()) private val _uiState = MutableStateFlow(DesireSyncUiState())
val uiState: StateFlow<DesireSyncUiState> = _uiState.asStateFlow() val uiState: StateFlow<DesireSyncUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init { init {
checkActiveSession() checkActiveSession()
load() load()
@ -138,6 +142,30 @@ class DesireSyncViewModel @Inject constructor(
} }
} }
private fun startSession() {
viewModelScope.launch {
gameSessionManager.startGameForCurrentUser(gameType = "desire_sync")
.onSuccess { gameHandle = it }
.onFailure { Log.w(TAG, "Could not start session", it) }
}
}
/** Marks the active session completed (idempotent — no-op if already finished). */
private suspend fun finishSession() {
val handle = gameHandle ?: return
gameHandle = null
gameSessionManager.finishGameForCurrentUser(handle)
.onFailure { Log.w(TAG, "Could not finish session", it) }
}
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val female = runCatching { repository.getDesireSyncQuestions("female") } val female = runCatching { repository.getDesireSyncQuestions("female") }
@ -169,8 +197,11 @@ class DesireSyncViewModel @Inject constructor(
} }
} }
fun startPartnerA() = _uiState.update { fun startPartnerA() {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0) startSession()
_uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
}
} }
fun select(optionId: String) { fun select(optionId: String) {
@ -224,6 +255,9 @@ class DesireSyncViewModel @Inject constructor(
) )
} }
} }
if (_uiState.value.phase == DesireSyncPhase.REVEAL) {
finishSession()
}
} }
} }
@ -298,7 +332,7 @@ fun DesireSyncScreen(
total = state.pairs.size, total = state.pairs.size,
pendingSelection = state.pendingSelection, pendingSelection = state.pendingSelection,
onSelect = viewModel::select, onSelect = viewModel::select,
onQuit = { onNavigate(AppRoute.PLAY) } onQuit = viewModel::quit
) )
} }
DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB) DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB)
@ -315,14 +349,14 @@ fun DesireSyncScreen(
total = state.pairs.size, total = state.pairs.size,
pendingSelection = state.pendingSelection, pendingSelection = state.pendingSelection,
onSelect = viewModel::selectB, onSelect = viewModel::selectB,
onQuit = { onNavigate(AppRoute.PLAY) } onQuit = viewModel::quit
) )
} }
DesireSyncPhase.REVEAL -> DSRevealScreen( DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches, matches = state.matches,
total = state.pairs.size, total = state.pairs.size,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) } onHome = viewModel::quit
) )
} }
} }

View File

@ -58,6 +58,7 @@ import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.ResultGlyph import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
@ -142,6 +143,9 @@ class HowWellViewModel @Inject constructor(
private val _uiState = MutableStateFlow(HowWellUiState()) private val _uiState = MutableStateFlow(HowWellUiState())
val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow() val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init { init {
checkActiveSession() checkActiveSession()
load() load()
@ -157,6 +161,30 @@ class HowWellViewModel @Inject constructor(
} }
} }
private fun startSession() {
viewModelScope.launch {
gameSessionManager.startGameForCurrentUser(gameType = "how_well")
.onSuccess { gameHandle = it }
.onFailure { Log.w(TAG, "Could not start session", it) }
}
}
/** Marks the active session completed (idempotent — no-op if already finished). */
private suspend fun finishSession() {
val handle = gameHandle ?: return
gameHandle = null
gameSessionManager.finishGameForCurrentUser(handle)
.onFailure { Log.w(TAG, "Could not finish session", it) }
}
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val questions = runCatching { val questions = runCatching {
@ -177,7 +205,10 @@ class HowWellViewModel @Inject constructor(
fun selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) } fun selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) }
fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) } fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) }
fun startPlayerA() = _uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) } fun startPlayerA() {
startSession()
_uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) }
}
fun confirmAnswer() { fun confirmAnswer() {
val s = _uiState.value val s = _uiState.value
@ -228,6 +259,9 @@ class HowWellViewModel @Inject constructor(
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN
) )
} }
if (next >= total) {
viewModelScope.launch { finishSession() }
}
} }
fun restart() { fun restart() {
@ -288,7 +322,7 @@ fun HowWellScreen(
onSelectOption = viewModel::selectOption, onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale, onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmAnswer, onConfirm = viewModel::confirmAnswer,
onQuit = { onNavigate(AppRoute.PLAY) } onQuit = viewModel::quit
) )
} }
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB) HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB)
@ -304,7 +338,7 @@ fun HowWellScreen(
onSelectOption = viewModel::selectOption, onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale, onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmPrediction, onConfirm = viewModel::confirmPrediction,
onQuit = { onNavigate(AppRoute.PLAY) } onQuit = viewModel::quit
) )
} }
HowWellPhase.REVEALING -> { HowWellPhase.REVEALING -> {
@ -322,7 +356,7 @@ fun HowWellScreen(
total = state.questions.size, total = state.questions.size,
results = state.results, results = state.results,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) } onHome = viewModel::quit
) )
} }
} }

View File

@ -64,6 +64,7 @@ import app.closer.domain.model.Question
import app.closer.domain.model.ThisOrThatAnswerConfig import app.closer.domain.model.ThisOrThatAnswerConfig
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
@ -99,6 +100,9 @@ class ThisOrThatViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ThisOrThatUiState()) private val _uiState = MutableStateFlow(ThisOrThatUiState())
val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow() val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init { init {
checkActiveSession() checkActiveSession()
load() load()
@ -129,6 +133,32 @@ class ThisOrThatViewModel @Inject constructor(
error = if (questions.isEmpty()) "No questions available." else null error = if (questions.isEmpty()) "No questions available." else null
) )
} }
// No intro screen — play begins immediately, so open the session now.
if (questions.isNotEmpty()) startSession()
}
}
private fun startSession() {
viewModelScope.launch {
gameSessionManager.startGameForCurrentUser(gameType = "this_or_that")
.onSuccess { gameHandle = it }
.onFailure { Log.w(TAG, "Could not start session", it) }
}
}
/** Marks the active session completed (idempotent — no-op if already finished). */
private suspend fun finishSession() {
val handle = gameHandle ?: return
gameHandle = null
gameSessionManager.finishGameForCurrentUser(handle)
.onFailure { Log.w(TAG, "Could not finish session", it) }
}
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
} }
} }
@ -154,6 +184,7 @@ class ThisOrThatViewModel @Inject constructor(
else else
it.copy(pendingSelection = null, currentIndex = next) it.copy(pendingSelection = null, currentIndex = next)
} }
if (_uiState.value.isComplete) finishSession()
} }
} }
@ -200,14 +231,14 @@ fun ThisOrThatScreen(
) )
state.error != null -> ErrorState( state.error != null -> ErrorState(
message = state.error!!, message = state.error!!,
onBack = { onNavigate(AppRoute.PLAY) } onBack = viewModel::quit
) )
state.isComplete -> ThisOrThatComplete( state.isComplete -> ThisOrThatComplete(
aCount = state.aCount, aCount = state.aCount,
bCount = state.bCount, bCount = state.bCount,
total = state.questions.size, total = state.questions.size,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) } onHome = viewModel::quit
) )
else -> { else -> {
val question = state.questions[state.currentIndex] val question = state.questions[state.currentIndex]
@ -219,7 +250,7 @@ fun ThisOrThatScreen(
total = state.questions.size, total = state.questions.size,
pendingSelection = state.pendingSelection, pendingSelection = state.pendingSelection,
onSelect = viewModel::select, onSelect = viewModel::select,
onBack = { onNavigate(AppRoute.PLAY) } onBack = viewModel::quit
) )
} }
} }