feat: add game history screen, shared session state, updated navigation + PlayHub
This commit is contained in:
parent
3ba6c659dd
commit
70e7c66cd6
|
|
@ -51,8 +51,11 @@ import app.closer.ui.play.PlayHubScreen
|
|||
import app.closer.ui.challenges.ConnectionChallengesScreen
|
||||
import app.closer.ui.memorylane.MemoryLaneScreen
|
||||
import app.closer.ui.desiresync.DesireSyncScreen
|
||||
import app.closer.ui.desiresync.DSReplayScreen
|
||||
import app.closer.ui.howwell.HowWellScreen
|
||||
import app.closer.ui.howwell.HowWellReplayScreen
|
||||
import app.closer.ui.thisorthat.ThisOrThatScreen
|
||||
import app.closer.ui.thisorthat.ThisOrThatReplayScreen
|
||||
import app.closer.ui.questions.DailyQuestionScreen
|
||||
import app.closer.ui.questions.QuestionCategoryScreen
|
||||
import app.closer.ui.questions.QuestionComposerScreen
|
||||
|
|
@ -304,6 +307,27 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.WHEEL_HISTORY) {
|
||||
WheelHistoryScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.GAME_HISTORY) {
|
||||
WheelHistoryScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.THIS_OR_THAT_REPLAY,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
ThisOrThatReplayScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.DESIRE_SYNC_REPLAY,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
DSReplayScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.HOW_WELL_REPLAY,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
HowWellReplayScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.THIS_OR_THAT) {
|
||||
ThisOrThatScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
|
|
@ -402,6 +426,11 @@ private val shellBackRoutes = setOf(
|
|||
AppRoute.CONNECTION_CHALLENGES,
|
||||
AppRoute.WAITING_FOR_PARTNER,
|
||||
AppRoute.SUBSCRIPTION,
|
||||
AppRoute.WHEEL_HISTORY,
|
||||
AppRoute.GAME_HISTORY,
|
||||
AppRoute.THIS_OR_THAT_REPLAY,
|
||||
AppRoute.DESIRE_SYNC_REPLAY,
|
||||
AppRoute.HOW_WELL_REPLAY,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ object AppRoute {
|
|||
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
||||
const val DELETE_ACCOUNT = "delete_account"
|
||||
const val WHEEL_HISTORY = "wheel_history"
|
||||
const val GAME_HISTORY = "game_history"
|
||||
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
|
||||
const val DESIRE_SYNC_REPLAY = "desire_sync_replay/{sessionId}"
|
||||
const val HOW_WELL_REPLAY = "how_well_replay/{sessionId}"
|
||||
const val DATE_MATCH = "date_match"
|
||||
const val DATE_MATCHES = "date_matches"
|
||||
const val DATE_BUILDER = "date_builder"
|
||||
|
|
@ -88,6 +92,10 @@ object AppRoute {
|
|||
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
|
||||
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
||||
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
|
||||
Definition(GAME_HISTORY, "Past Games", "play"),
|
||||
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
|
||||
Definition(DESIRE_SYNC_REPLAY, "Desire Sync Results", "play"),
|
||||
Definition(HOW_WELL_REPLAY, "How Well Results", "play"),
|
||||
Definition(DATE_MATCH, "Date Match", "dates"),
|
||||
Definition(DATE_MATCHES, "Matches", "dates"),
|
||||
Definition(DATE_BUILDER, "Plan a Date", "dates"),
|
||||
|
|
@ -135,6 +143,10 @@ object AppRoute {
|
|||
WHEEL_SESSION,
|
||||
WHEEL_COMPLETE,
|
||||
WHEEL_HISTORY,
|
||||
GAME_HISTORY,
|
||||
THIS_OR_THAT_REPLAY,
|
||||
DESIRE_SYNC_REPLAY,
|
||||
HOW_WELL_REPLAY,
|
||||
DATE_MATCH,
|
||||
DATE_MATCHES,
|
||||
DATE_BUILDER,
|
||||
|
|
@ -165,6 +177,12 @@ object AppRoute {
|
|||
|
||||
fun wheelComplete(sessionId: String): String = "wheel_complete/${sessionId.asRouteArg()}"
|
||||
|
||||
fun thisOrThatReplay(sessionId: String): String = "this_or_that_replay/${sessionId.asRouteArg()}"
|
||||
|
||||
fun desireSyncReplay(sessionId: String): String = "desire_sync_replay/${sessionId.asRouteArg()}"
|
||||
|
||||
fun howWellReplay(sessionId: String): String = "how_well_replay/${sessionId.asRouteArg()}"
|
||||
|
||||
fun questionThread(
|
||||
coupleId: String,
|
||||
questionId: String,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
|||
.await()
|
||||
}
|
||||
|
||||
/** One-shot load of both partners' picks (for history replay). */
|
||||
suspend fun getAnswers(coupleId: String, sessionId: String): ThisOrThatAnswers? =
|
||||
runCatching {
|
||||
val snap = doc(coupleId, sessionId).get().await()
|
||||
if (!snap.exists()) return@runCatching null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val raw = snap.get("answers") as? Map<String, *>
|
||||
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
|
||||
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
|
||||
}.toMap()
|
||||
ThisOrThatAnswers(byUser)
|
||||
}.getOrNull()
|
||||
|
||||
/** Live view of both partners' picks; emits whenever either side submits. */
|
||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
||||
callbackFlow {
|
||||
|
|
|
|||
|
|
@ -189,4 +189,34 @@ class QuestionSessionRepositoryImpl @Inject constructor(
|
|||
)
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
override suspend fun getSessionById(coupleId: String, sessionId: String): QuestionSession? =
|
||||
runCatching {
|
||||
firestore.collection(FirestoreCollections.COUPLES)
|
||||
.document(coupleId)
|
||||
.collection(FirestoreCollections.Couples.SESSIONS)
|
||||
.document(sessionId)
|
||||
.get()
|
||||
.await()
|
||||
.takeIf { it.exists() }
|
||||
?.let { doc ->
|
||||
runCatching {
|
||||
QuestionSession(
|
||||
id = doc.getString("id") ?: doc.id,
|
||||
coupleId = doc.getString("coupleId") ?: coupleId,
|
||||
categoryId = doc.getString("categoryId") ?: "",
|
||||
questionIds = (doc.get("questionIds") as? List<*>)
|
||||
?.filterIsInstance<String>() ?: emptyList(),
|
||||
startedByUserId = doc.getString("startedByUserId") ?: "",
|
||||
startedAt = doc.getLong("startedAt") ?: 0L,
|
||||
completedAt = doc.getLong("completedAt"),
|
||||
isPremium = doc.getBoolean("isPremium") ?: false,
|
||||
status = doc.getString("status") ?: "completed",
|
||||
gameType = doc.getString("gameType") ?: GameType.WHEEL,
|
||||
completedByUsers = (doc.get("completedByUsers") as? List<*>)
|
||||
?.filterIsInstance<String>() ?: emptyList()
|
||||
)
|
||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,7 @@ interface QuestionSessionRepository {
|
|||
|
||||
// Force-complete the active session (escape hatch for stuck/abandoned games).
|
||||
suspend fun abandonSession(coupleId: String): Result<Unit>
|
||||
|
||||
// Single-session lookup by ID (for history replay).
|
||||
suspend fun getSessionById(coupleId: String, sessionId: String): QuestionSession?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.navigation.AppRoute
|
||||
|
|
@ -57,6 +58,7 @@ import app.closer.data.remote.FirestoreDesireSyncDataSource
|
|||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||
import app.closer.domain.model.Question
|
||||
import app.closer.domain.repository.QuestionRepository
|
||||
import app.closer.domain.repository.QuestionSessionRepository
|
||||
import app.closer.domain.usecase.GameSessionManager
|
||||
import app.closer.ui.components.StatusGlyph
|
||||
import app.closer.ui.theme.CloserPalette
|
||||
|
|
@ -847,3 +849,105 @@ private fun DesireMatchCard(match: DesireMatch) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── History Replay ────────────────────────────────────────────────────────────
|
||||
|
||||
sealed interface DSReplayPhase {
|
||||
object Loading : DSReplayPhase
|
||||
data class Ready(val matches: List<DesireMatch>, val total: Int) : DSReplayPhase
|
||||
data class Error(val message: String) : DSReplayPhase
|
||||
}
|
||||
|
||||
data class DSReplayUiState(
|
||||
val phase: DSReplayPhase = DSReplayPhase.Loading,
|
||||
val partnerName: String = "Your partner"
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DSReplayViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val answerDataSource: FirestoreDesireSyncDataSource,
|
||||
private val questionRepository: QuestionRepository,
|
||||
private val sessionRepository: QuestionSessionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||
private val _uiState = MutableStateFlow(DSReplayUiState())
|
||||
val uiState: StateFlow<DSReplayUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val uid = gameSessionManager.currentUserId ?: run {
|
||||
_uiState.update { it.copy(phase = DSReplayPhase.Error("Not signed in")) }
|
||||
return@launch
|
||||
}
|
||||
val couple = gameSessionManager.getCoupleForUser(uid) ?: run {
|
||||
_uiState.update { it.copy(phase = DSReplayPhase.Error("No couple found")) }
|
||||
return@launch
|
||||
}
|
||||
val partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName
|
||||
?: "Your partner"
|
||||
_uiState.update { it.copy(partnerName = partnerName) }
|
||||
|
||||
val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = DSReplayPhase.Error("Session not found")) }
|
||||
return@launch
|
||||
}
|
||||
val answers = answerDataSource.getAnswers(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = DSReplayPhase.Error("Answers not found")) }
|
||||
return@launch
|
||||
}
|
||||
val questions = session.questionIds.mapNotNull { questionRepository.getQuestionById(it) }
|
||||
val mine = answers.byUser[uid].orEmpty()
|
||||
val theirs = partnerId?.let { answers.byUser[it] }.orEmpty()
|
||||
val matches = questions.indices.mapNotNull { i ->
|
||||
val a = mine.getOrNull(i)?.lowercase()
|
||||
val b = theirs.getOrNull(i)?.lowercase()
|
||||
if (a != null && b != null && a in POSITIVE_IDS && b in POSITIVE_IDS) {
|
||||
DesireMatch(questions[i])
|
||||
} else null
|
||||
}
|
||||
_uiState.update { it.copy(phase = DSReplayPhase.Ready(matches, questions.size)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DSReplayScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: DSReplayViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val phase = state.phase
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(closerBackgroundBrush())
|
||||
) {
|
||||
when (phase) {
|
||||
is DSReplayPhase.Loading -> CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = CloserPalette.Romantic
|
||||
)
|
||||
is DSReplayPhase.Error -> Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(phase.message, textAlign = TextAlign.Center)
|
||||
OutlinedButton(onClick = { onNavigate(AppRoute.GAME_HISTORY) }) { Text("Back") }
|
||||
}
|
||||
is DSReplayPhase.Ready -> DSRevealScreen(
|
||||
matches = phase.matches,
|
||||
total = phase.total,
|
||||
partnerName = state.partnerName,
|
||||
onPlayAgain = { onNavigate(AppRoute.DESIRE_SYNC) },
|
||||
onHome = { onNavigate(AppRoute.PLAY) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.navigation.AppRoute
|
||||
|
|
@ -63,6 +64,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.repository.QuestionSessionRepository
|
||||
import app.closer.data.remote.FirestoreHowWellDataSource
|
||||
import app.closer.data.remote.HowWellAnswers
|
||||
import app.closer.data.remote.HowWellRawAnswer
|
||||
|
|
@ -1027,3 +1029,113 @@ private fun scoreLabel(score: Int, total: Int): String = when {
|
|||
score >= total * 0.4 -> "Getting there!"
|
||||
else -> "Room to grow"
|
||||
}
|
||||
|
||||
// ── History Replay ────────────────────────────────────────────────────────────
|
||||
|
||||
sealed interface HWReplayPhase {
|
||||
object Loading : HWReplayPhase
|
||||
data class Ready(val score: Int, val total: Int, val results: List<HowWellResult>, val amSubject: Boolean) : HWReplayPhase
|
||||
data class Error(val message: String) : HWReplayPhase
|
||||
}
|
||||
|
||||
data class HWReplayUiState(
|
||||
val phase: HWReplayPhase = HWReplayPhase.Loading,
|
||||
val partnerName: String = "Your partner"
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HowWellReplayViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val answerDataSource: FirestoreHowWellDataSource,
|
||||
private val questionRepository: QuestionRepository,
|
||||
private val sessionRepository: QuestionSessionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||
private val _uiState = MutableStateFlow(HWReplayUiState())
|
||||
val uiState: StateFlow<HWReplayUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val uid = gameSessionManager.currentUserId ?: run {
|
||||
_uiState.update { it.copy(phase = HWReplayPhase.Error("Not signed in")) }
|
||||
return@launch
|
||||
}
|
||||
val couple = gameSessionManager.getCoupleForUser(uid) ?: run {
|
||||
_uiState.update { it.copy(phase = HWReplayPhase.Error("No couple found")) }
|
||||
return@launch
|
||||
}
|
||||
val partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName
|
||||
?: "Your partner"
|
||||
_uiState.update { it.copy(partnerName = partnerName) }
|
||||
|
||||
val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = HWReplayPhase.Error("Session not found")) }
|
||||
return@launch
|
||||
}
|
||||
val answers = answerDataSource.getAnswers(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = HWReplayPhase.Error("Answers not found")) }
|
||||
return@launch
|
||||
}
|
||||
val questions = session.questionIds.mapNotNull { questionRepository.getQuestionById(it) }
|
||||
|
||||
val subjectId = session.startedByUserId
|
||||
val guesserId = if (subjectId == uid) partnerId else uid
|
||||
val subjectAnswers = answers.byUser[subjectId].orEmpty()
|
||||
val guesserAnswers = guesserId?.let { answers.byUser[it] }.orEmpty()
|
||||
val results = questions.mapIndexed { i, q ->
|
||||
val actual = subjectAnswers.getOrNull(i).toAnswer()
|
||||
val prediction = guesserAnswers.getOrNull(i).toAnswer()
|
||||
HowWellResult(q, actual, prediction, prediction.isMatch(actual), prediction.isClose(actual))
|
||||
}
|
||||
val score = results.count { it.isMatch }
|
||||
val amSubject = subjectId == uid
|
||||
_uiState.update { it.copy(phase = HWReplayPhase.Ready(score, questions.size, results, amSubject)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun HowWellRawAnswer?.toAnswer(): HowWellAnswer =
|
||||
HowWellAnswer(this?.optionId, this?.scale)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HowWellReplayScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: HowWellReplayViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val phase = state.phase
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(closerBackgroundBrush())
|
||||
) {
|
||||
when (phase) {
|
||||
is HWReplayPhase.Loading -> CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = CloserPalette.PurpleDeep
|
||||
)
|
||||
is HWReplayPhase.Error -> Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(phase.message, textAlign = TextAlign.Center)
|
||||
OutlinedButton(onClick = { onNavigate(AppRoute.GAME_HISTORY) }) { Text("Back") }
|
||||
}
|
||||
is HWReplayPhase.Ready -> CompleteScreen(
|
||||
score = phase.score,
|
||||
total = phase.total,
|
||||
results = phase.results,
|
||||
amSubject = phase.amSubject,
|
||||
partnerName = state.partnerName,
|
||||
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
|
||||
onHome = { onNavigate(AppRoute.PLAY) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,12 +166,12 @@ private fun PlayHubContent(
|
|||
onClick = { onNavigate(AppRoute.BUCKET_LIST) }
|
||||
)
|
||||
CompactPlayCard(
|
||||
title = "Wheel History",
|
||||
subtitle = "Past sessions",
|
||||
title = "Past Games",
|
||||
subtitle = "All results",
|
||||
icon = Icons.Filled.Home,
|
||||
tint = CloserPalette.PurpleDeep,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onNavigate(AppRoute.WHEEL_HISTORY) }
|
||||
onClick = { onNavigate(AppRoute.GAME_HISTORY) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.navigation.AppRoute
|
||||
|
|
@ -68,6 +69,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.repository.QuestionSessionRepository
|
||||
import app.closer.domain.usecase.GameSessionManager
|
||||
import app.closer.ui.theme.CloserPalette
|
||||
import app.closer.ui.theme.closerBackgroundBrush
|
||||
|
|
@ -992,6 +994,122 @@ private fun ErrorState(message: String, onBack: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── History Replay ────────────────────────────────────────────────────────────
|
||||
|
||||
sealed interface TotReplayPhase {
|
||||
object Loading : TotReplayPhase
|
||||
data class Ready(val matched: Int, val total: Int, val cards: List<RevealCard>) : TotReplayPhase
|
||||
data class Error(val message: String) : TotReplayPhase
|
||||
}
|
||||
|
||||
data class TotReplayUiState(
|
||||
val phase: TotReplayPhase = TotReplayPhase.Loading,
|
||||
val partnerName: String = "Your partner"
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ThisOrThatReplayViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val answerDataSource: FirestoreThisOrThatDataSource,
|
||||
private val questionRepository: QuestionRepository,
|
||||
private val sessionRepository: QuestionSessionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||
private val _uiState = MutableStateFlow(TotReplayUiState())
|
||||
val uiState: StateFlow<TotReplayUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val uid = gameSessionManager.currentUserId ?: run {
|
||||
_uiState.update { it.copy(phase = TotReplayPhase.Error("Not signed in")) }
|
||||
return@launch
|
||||
}
|
||||
val couple = gameSessionManager.getCoupleForUser(uid) ?: run {
|
||||
_uiState.update { it.copy(phase = TotReplayPhase.Error("No couple found")) }
|
||||
return@launch
|
||||
}
|
||||
val partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName
|
||||
?: "Your partner"
|
||||
_uiState.update { it.copy(partnerName = partnerName) }
|
||||
|
||||
val session = sessionRepository.getSessionById(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = TotReplayPhase.Error("Session not found")) }
|
||||
return@launch
|
||||
}
|
||||
val answers = answerDataSource.getAnswers(couple.id, sessionId) ?: run {
|
||||
_uiState.update { it.copy(phase = TotReplayPhase.Error("Answers not found")) }
|
||||
return@launch
|
||||
}
|
||||
val questions = session.questionIds.mapNotNull { questionRepository.getQuestionById(it) }
|
||||
val mine = answers.byUser[uid].orEmpty()
|
||||
val theirs = partnerId?.let { answers.byUser[it] }.orEmpty()
|
||||
val cards = questions.mapIndexed { i, q ->
|
||||
val config = q.answerConfig as? ThisOrThatAnswerConfigImpl
|
||||
val myOpt = mine.getOrNull(i)
|
||||
val theirOpt = theirs.getOrNull(i)
|
||||
RevealCard(
|
||||
questionText = q.text,
|
||||
myText = replayOptionText(config, myOpt),
|
||||
partnerText = replayOptionText(config, theirOpt),
|
||||
agreed = myOpt != null && myOpt == theirOpt
|
||||
)
|
||||
}
|
||||
val matched = cards.count { it.agreed }
|
||||
_uiState.update { it.copy(phase = TotReplayPhase.Ready(matched, cards.size, cards)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun replayOptionText(config: ThisOrThatAnswerConfigImpl?, optionId: String?): String =
|
||||
when (optionId) {
|
||||
null -> "—"
|
||||
config?.config?.optionA?.id -> config.config.optionA.text
|
||||
config?.config?.optionB?.id -> config.config.optionB.text
|
||||
else -> optionId
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThisOrThatReplayScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: ThisOrThatReplayViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val phase = state.phase
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(closerBackgroundBrush())
|
||||
) {
|
||||
when (phase) {
|
||||
is TotReplayPhase.Loading -> CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = CloserPalette.PurpleDeep
|
||||
)
|
||||
is TotReplayPhase.Error -> Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(phase.message, textAlign = TextAlign.Center)
|
||||
OutlinedButton(onClick = { onNavigate(AppRoute.GAME_HISTORY) }) { Text("Back") }
|
||||
}
|
||||
is TotReplayPhase.Ready -> ThisOrThatReveal(
|
||||
matched = phase.matched,
|
||||
total = phase.total,
|
||||
partnerName = state.partnerName,
|
||||
cards = phase.cards,
|
||||
onPlayAgain = { onNavigate(AppRoute.THIS_OR_THAT) },
|
||||
onHome = { onNavigate(AppRoute.PLAY) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.GameType
|
||||
import app.closer.domain.model.QuestionSession
|
||||
import app.closer.ui.components.EmptyState
|
||||
import app.closer.ui.components.ErrorState
|
||||
|
|
@ -92,7 +93,7 @@ fun WheelHistoryScreen(
|
|||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Spin sessions",
|
||||
text = "Past games",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp)
|
||||
|
|
@ -113,13 +114,16 @@ fun WheelHistoryScreen(
|
|||
state.sessions.isEmpty() -> item {
|
||||
EmptyState(
|
||||
title = "No sessions yet",
|
||||
body = "Completed spin wheel sessions will appear here.",
|
||||
actionLabel = "Spin now",
|
||||
onAction = { onNavigate(AppRoute.CATEGORY_PICKER) }
|
||||
body = "Completed games will appear here.",
|
||||
actionLabel = "Play now",
|
||||
onAction = { onNavigate(AppRoute.PLAY) }
|
||||
)
|
||||
}
|
||||
else -> items(state.sessions, key = { it.id }) { session ->
|
||||
WheelSessionCard(session = session)
|
||||
WheelSessionCard(
|
||||
session = session,
|
||||
onClick = { onNavigate(sessionReplayRoute(session)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,8 +132,9 @@ fun WheelHistoryScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun WheelSessionCard(session: QuestionSession) {
|
||||
private fun WheelSessionCard(session: QuestionSession, onClick: () -> Unit = {}) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
|
||||
|
|
@ -142,7 +147,7 @@ private fun WheelSessionCard(session: QuestionSession) {
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = session.categoryId.displayCategoryName(),
|
||||
text = sessionTitle(session),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
|
|
@ -164,6 +169,20 @@ private fun WheelSessionCard(session: QuestionSession) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun sessionTitle(session: QuestionSession): String = when (session.gameType) {
|
||||
GameType.THIS_OR_THAT -> "This or That"
|
||||
GameType.HOW_WELL -> "How Well Do You Know Me"
|
||||
GameType.DESIRE_SYNC -> "Desire Sync"
|
||||
else -> session.categoryId.displayCategoryName().ifBlank { "Spin Wheel" }
|
||||
}
|
||||
|
||||
private fun sessionReplayRoute(session: QuestionSession): String = when (session.gameType) {
|
||||
GameType.THIS_OR_THAT -> AppRoute.thisOrThatReplay(session.id)
|
||||
GameType.HOW_WELL -> AppRoute.howWellReplay(session.id)
|
||||
GameType.DESIRE_SYNC -> AppRoute.desireSyncReplay(session.id)
|
||||
else -> AppRoute.wheelComplete(session.id)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
|
||||
Card(
|
||||
|
|
@ -197,7 +216,7 @@ private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Unlock to browse all your past spin wheel sessions together.",
|
||||
text = "Unlock to browse all your past game results together.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
|
|
|
|||
Loading…
Reference in New Issue