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.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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue