From 4134c4570dd5a093ef6f79c8187ac9abe7e12190 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 18 Jun 2026 02:29:14 -0500 Subject: [PATCH] fix: integrate game session lifecycle into DesireSync, HowWell, and ThisOrThat --- .../domain/usecase/GameSessionManager.kt | 41 ++++++++++++++++- .../closer/ui/desiresync/DesireSyncScreen.kt | 44 ++++++++++++++++--- .../app/closer/ui/howwell/HowWellScreen.kt | 42 ++++++++++++++++-- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 37 ++++++++++++++-- 4 files changed, 151 insertions(+), 13 deletions(-) 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 95308bc1..873937c5 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -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? = null + ): Result { + 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 { + 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() } /** 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 37eeb486..b6d41138 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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 = _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 ) } } 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 f3890a36..8599e62e 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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 = _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 ) } } 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 84e3976e..760703fa 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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 = _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 ) } }