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.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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,9 +197,12 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startPartnerA() = _uiState.update {
|
fun startPartnerA() {
|
||||||
|
startSession()
|
||||||
|
_uiState.update {
|
||||||
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
|
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun select(optionId: String) {
|
fun select(optionId: String) {
|
||||||
val s = _uiState.value
|
val s = _uiState.value
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue