fix: integrate game session lifecycle into DesireSync, HowWell, and ThisOrThat
This commit is contained in:
parent
2b8e05b29b
commit
4134c4570d
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue