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.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.
* Enforces "one active game per couple" rule.
@ -66,6 +75,33 @@ class GameSessionManager @Inject constructor(
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.
* Marks the session as completed by this user.
@ -89,7 +125,10 @@ class GameSessionManager @Inject constructor(
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.Question
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette
@ -123,6 +124,9 @@ class DesireSyncViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DesireSyncUiState())
val uiState: StateFlow<DesireSyncUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init {
checkActiveSession()
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() {
viewModelScope.launch {
val female = runCatching { repository.getDesireSyncQuestions("female") }
@ -169,8 +197,11 @@ class DesireSyncViewModel @Inject constructor(
}
}
fun startPartnerA() = _uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
fun startPartnerA() {
startSession()
_uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
}
}
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,
pendingSelection = state.pendingSelection,
onSelect = viewModel::select,
onQuit = { onNavigate(AppRoute.PLAY) }
onQuit = viewModel::quit
)
}
DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB)
@ -315,14 +349,14 @@ fun DesireSyncScreen(
total = state.pairs.size,
pendingSelection = state.pendingSelection,
onSelect = viewModel::selectB,
onQuit = { onNavigate(AppRoute.PLAY) }
onQuit = viewModel::quit
)
}
DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches,
total = state.pairs.size,
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.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph
@ -142,6 +143,9 @@ class HowWellViewModel @Inject constructor(
private val _uiState = MutableStateFlow(HowWellUiState())
val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init {
checkActiveSession()
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() {
viewModelScope.launch {
val questions = runCatching {
@ -177,7 +205,10 @@ class HowWellViewModel @Inject constructor(
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 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() {
val s = _uiState.value
@ -228,6 +259,9 @@ class HowWellViewModel @Inject constructor(
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN
)
}
if (next >= total) {
viewModelScope.launch { finishSession() }
}
}
fun restart() {
@ -288,7 +322,7 @@ fun HowWellScreen(
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmAnswer,
onQuit = { onNavigate(AppRoute.PLAY) }
onQuit = viewModel::quit
)
}
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB)
@ -304,7 +338,7 @@ fun HowWellScreen(
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmPrediction,
onQuit = { onNavigate(AppRoute.PLAY) }
onQuit = viewModel::quit
)
}
HowWellPhase.REVEALING -> {
@ -322,7 +356,7 @@ fun HowWellScreen(
total = state.questions.size,
results = state.results,
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.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
@ -99,6 +100,9 @@ class ThisOrThatViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ThisOrThatUiState())
val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */
private var gameHandle: GameHandle? = null
init {
checkActiveSession()
load()
@ -129,6 +133,32 @@ class ThisOrThatViewModel @Inject constructor(
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
it.copy(pendingSelection = null, currentIndex = next)
}
if (_uiState.value.isComplete) finishSession()
}
}
@ -200,14 +231,14 @@ fun ThisOrThatScreen(
)
state.error != null -> ErrorState(
message = state.error!!,
onBack = { onNavigate(AppRoute.PLAY) }
onBack = viewModel::quit
)
state.isComplete -> ThisOrThatComplete(
aCount = state.aCount,
bCount = state.bCount,
total = state.questions.size,
onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) }
onHome = viewModel::quit
)
else -> {
val question = state.questions[state.currentIndex]
@ -219,7 +250,7 @@ fun ThisOrThatScreen(
total = state.questions.size,
pendingSelection = state.pendingSelection,
onSelect = viewModel::select,
onBack = { onNavigate(AppRoute.PLAY) }
onBack = viewModel::quit
)
}
}