feat: add game history screen, shared session state, updated navigation + PlayHub

This commit is contained in:
null 2026-06-19 01:43:06 -05:00
parent 3ba6c659dd
commit 70e7c66cd6
10 changed files with 457 additions and 11 deletions

View File

@ -51,8 +51,11 @@ import app.closer.ui.play.PlayHubScreen
import app.closer.ui.challenges.ConnectionChallengesScreen import app.closer.ui.challenges.ConnectionChallengesScreen
import app.closer.ui.memorylane.MemoryLaneScreen import app.closer.ui.memorylane.MemoryLaneScreen
import app.closer.ui.desiresync.DesireSyncScreen import app.closer.ui.desiresync.DesireSyncScreen
import app.closer.ui.desiresync.DSReplayScreen
import app.closer.ui.howwell.HowWellScreen import app.closer.ui.howwell.HowWellScreen
import app.closer.ui.howwell.HowWellReplayScreen
import app.closer.ui.thisorthat.ThisOrThatScreen import app.closer.ui.thisorthat.ThisOrThatScreen
import app.closer.ui.thisorthat.ThisOrThatReplayScreen
import app.closer.ui.questions.DailyQuestionScreen import app.closer.ui.questions.DailyQuestionScreen
import app.closer.ui.questions.QuestionCategoryScreen import app.closer.ui.questions.QuestionCategoryScreen
import app.closer.ui.questions.QuestionComposerScreen import app.closer.ui.questions.QuestionComposerScreen
@ -304,6 +307,27 @@ fun AppNavigation(
composable(route = AppRoute.WHEEL_HISTORY) { composable(route = AppRoute.WHEEL_HISTORY) {
WheelHistoryScreen(onNavigate = navigateRoute) 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) { composable(route = AppRoute.THIS_OR_THAT) {
ThisOrThatScreen(onNavigate = navigateRoute) ThisOrThatScreen(onNavigate = navigateRoute)
} }
@ -402,6 +426,11 @@ private val shellBackRoutes = setOf(
AppRoute.CONNECTION_CHALLENGES, AppRoute.CONNECTION_CHALLENGES,
AppRoute.WAITING_FOR_PARTNER, AppRoute.WAITING_FOR_PARTNER,
AppRoute.SUBSCRIPTION, AppRoute.SUBSCRIPTION,
AppRoute.WHEEL_HISTORY,
AppRoute.GAME_HISTORY,
AppRoute.THIS_OR_THAT_REPLAY,
AppRoute.DESIRE_SYNC_REPLAY,
AppRoute.HOW_WELL_REPLAY,
) )
@Composable @Composable

View File

@ -34,6 +34,10 @@ object AppRoute {
const val RELATIONSHIP_SETTINGS = "relationship_settings" const val RELATIONSHIP_SETTINGS = "relationship_settings"
const val DELETE_ACCOUNT = "delete_account" const val DELETE_ACCOUNT = "delete_account"
const val WHEEL_HISTORY = "wheel_history" 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_MATCH = "date_match"
const val DATE_MATCHES = "date_matches" const val DATE_MATCHES = "date_matches"
const val DATE_BUILDER = "date_builder" const val DATE_BUILDER = "date_builder"
@ -88,6 +92,10 @@ object AppRoute {
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"), Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
Definition(DELETE_ACCOUNT, "Delete Account", "settings"), Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel"), 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_MATCH, "Date Match", "dates"),
Definition(DATE_MATCHES, "Matches", "dates"), Definition(DATE_MATCHES, "Matches", "dates"),
Definition(DATE_BUILDER, "Plan a Date", "dates"), Definition(DATE_BUILDER, "Plan a Date", "dates"),
@ -135,6 +143,10 @@ object AppRoute {
WHEEL_SESSION, WHEEL_SESSION,
WHEEL_COMPLETE, WHEEL_COMPLETE,
WHEEL_HISTORY, WHEEL_HISTORY,
GAME_HISTORY,
THIS_OR_THAT_REPLAY,
DESIRE_SYNC_REPLAY,
HOW_WELL_REPLAY,
DATE_MATCH, DATE_MATCH,
DATE_MATCHES, DATE_MATCHES,
DATE_BUILDER, DATE_BUILDER,
@ -165,6 +177,12 @@ object AppRoute {
fun wheelComplete(sessionId: String): String = "wheel_complete/${sessionId.asRouteArg()}" 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( fun questionThread(
coupleId: String, coupleId: String,
questionId: String, questionId: String,

View File

@ -47,6 +47,19 @@ class FirestoreThisOrThatDataSource @Inject constructor(
.await() .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. */ /** Live view of both partners' picks; emits whenever either side submits. */
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> = fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
callbackFlow { callbackFlow {

View File

@ -189,4 +189,34 @@ class QuestionSessionRepositoryImpl @Inject constructor(
) )
).getOrThrow() ).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()
} }

View File

@ -17,4 +17,7 @@ interface QuestionSessionRepository {
// Force-complete the active session (escape hatch for stuck/abandoned games). // Force-complete the active session (escape hatch for stuck/abandoned games).
suspend fun abandonSession(coupleId: String): Result<Unit> suspend fun abandonSession(coupleId: String): Result<Unit>
// Single-session lookup by ID (for history replay).
suspend fun getSessionById(coupleId: String, sessionId: String): QuestionSession?
} }

View File

@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute 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.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.repository.QuestionSessionRepository
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
@ -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) }
)
}
}
}

View File

@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute 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.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.repository.QuestionSessionRepository
import app.closer.data.remote.FirestoreHowWellDataSource import app.closer.data.remote.FirestoreHowWellDataSource
import app.closer.data.remote.HowWellAnswers import app.closer.data.remote.HowWellAnswers
import app.closer.data.remote.HowWellRawAnswer 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!" score >= total * 0.4 -> "Getting there!"
else -> "Room to grow" 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) }
)
}
}
}

View File

@ -166,12 +166,12 @@ private fun PlayHubContent(
onClick = { onNavigate(AppRoute.BUCKET_LIST) } onClick = { onNavigate(AppRoute.BUCKET_LIST) }
) )
CompactPlayCard( CompactPlayCard(
title = "Wheel History", title = "Past Games",
subtitle = "Past sessions", subtitle = "All results",
icon = Icons.Filled.Home, icon = Icons.Filled.Home,
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.WHEEL_HISTORY) } onClick = { onNavigate(AppRoute.GAME_HISTORY) }
) )
} }
} }

View File

@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute 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.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.repository.QuestionSessionRepository
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
@ -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 ───────────────────────────────────────────────────────────────────
@Preview @Preview

View File

@ -45,6 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionSession import app.closer.domain.model.QuestionSession
import app.closer.ui.components.EmptyState import app.closer.ui.components.EmptyState
import app.closer.ui.components.ErrorState import app.closer.ui.components.ErrorState
@ -92,7 +93,7 @@ fun WheelHistoryScreen(
) { ) {
item { item {
Text( Text(
text = "Spin sessions", text = "Past games",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp) modifier = Modifier.padding(top = 20.dp, bottom = 4.dp)
@ -113,13 +114,16 @@ fun WheelHistoryScreen(
state.sessions.isEmpty() -> item { state.sessions.isEmpty() -> item {
EmptyState( EmptyState(
title = "No sessions yet", title = "No sessions yet",
body = "Completed spin wheel sessions will appear here.", body = "Completed games will appear here.",
actionLabel = "Spin now", actionLabel = "Play now",
onAction = { onNavigate(AppRoute.CATEGORY_PICKER) } onAction = { onNavigate(AppRoute.PLAY) }
) )
} }
else -> items(state.sessions, key = { it.id }) { session -> 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 @Composable
private fun WheelSessionCard(session: QuestionSession) { private fun WheelSessionCard(session: QuestionSession, onClick: () -> Unit = {}) {
Card( Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)), 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)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = session.categoryId.displayCategoryName(), text = sessionTitle(session),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface 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 @Composable
private fun WheelHistoryLockedCard(onUnlock: () -> Unit) { private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
Card( Card(
@ -197,7 +216,7 @@ private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 3,