From 408a2f24bafb07ec308a32831b7fb23f9fa3a345 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 18 Jun 2026 19:53:17 -0500 Subject: [PATCH] feat: add ThisOrThat Firestore data source, updated screen with question answering flow --- .../data/remote/FirestoreCollections.kt | 1 + .../remote/FirestoreThisOrThatDataSource.kt | 64 ++ .../closer/ui/thisorthat/ThisOrThatScreen.kt | 630 ++++++++++++------ 3 files changed, 507 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index 2bc8ca99..fcb72130 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -32,6 +32,7 @@ object FirestoreCollections { const val DAILY_QUESTION = "daily_question" const val CHALLENGES = "challenges" const val CAPSULES = "capsules" + const val THIS_OR_THAT = "this_or_that" } // ── Subcollections under couples/{coupleId}/daily_question/{date} ─────────── diff --git a/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt new file mode 100644 index 00000000..9e86c417 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt @@ -0,0 +1,64 @@ +package app.closer.data.remote + +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Both partners' picks for one This-or-That session, keyed by userId. Each list + * holds the chosen optionIds aligned to the session's `questionIds` order, so + * `byUser[a][i]` and `byUser[b][i]` are answers to the same prompt. + */ +data class ThisOrThatAnswers( + val byUser: Map> = emptyMap() +) + +/** + * Stores per-user answers for the async This-or-That reveal at + * `couples/{coupleId}/this_or_that/{sessionId}`. The session itself (the shared + * question set + the one-active-game lock) is owned by [app.closer.domain.usecase.GameSessionManager]; + * this only carries the answers so each partner can play on their own device and + * the result reveals once both have submitted. + */ +@Singleton +class FirestoreThisOrThatDataSource @Inject constructor( + private val db: FirebaseFirestore +) { + private fun doc(coupleId: String, sessionId: String) = + db.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.THIS_OR_THAT) + .document(sessionId) + + /** Persist this user's full set of picks (optionIds in `questionIds` order). */ + suspend fun submitAnswers( + coupleId: String, + sessionId: String, + userId: String, + optionIds: List + ) { + doc(coupleId, sessionId) + .set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge()) + .await() + } + + /** Live view of both partners' picks; emits whenever either side submits. */ + fun observeAnswers(coupleId: String, sessionId: String): Flow = + callbackFlow { + val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> + if (err != null || snap == null) return@addSnapshotListener + @Suppress("UNCHECKED_CAST") + val raw = snap.get("answers") as? Map + val byUser = raw.orEmpty().mapNotNull { (uid, value) -> + (value as? List<*>)?.filterIsInstance()?.let { uid to it } + }.toMap() + trySend(ThisOrThatAnswers(byUser)) + } + awaitClose { reg.remove() } + } +} diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 12040154..15b2908b 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -23,10 +23,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -39,7 +42,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -60,17 +62,18 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.navigation.AppRoute +import app.closer.data.remote.FirestoreThisOrThatDataSource import app.closer.domain.model.ChoiceOption import app.closer.domain.model.Question import app.closer.domain.model.ThisOrThatAnswerConfig import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository -import app.closer.domain.usecase.GameHandle import app.closer.domain.usecase.GameSessionManager import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -80,14 +83,25 @@ import kotlinx.coroutines.launch // ── ViewModel ──────────────────────────────────────────────────────────────── +enum class TotPhase { LOADING, PLAYING, WAITING, REVEAL, ERROR } + +/** One prompt's outcome: what each partner picked and whether they matched. */ +data class RevealCard( + val questionText: String, + val myText: String, + val partnerText: String, + val agreed: Boolean +) + data class ThisOrThatUiState( - val isLoading: Boolean = true, + val phase: TotPhase = TotPhase.LOADING, val questions: List = emptyList(), val currentIndex: Int = 0, val pendingSelection: String? = null, - val aCount: Int = 0, - val bCount: Int = 0, - val isComplete: Boolean = false, + val myAnswers: List = emptyList(), + val partnerName: String = "Your partner", + val matchedCount: Int = 0, + val revealCards: List = emptyList(), val error: String? = null, val navigateTo: String? = null ) @@ -95,101 +109,208 @@ data class ThisOrThatUiState( @HiltViewModel class ThisOrThatViewModel @Inject constructor( private val repository: QuestionRepository, - private val gameSessionManager: GameSessionManager + private val gameSessionManager: GameSessionManager, + private val dataSource: FirestoreThisOrThatDataSource ) : ViewModel() { private val _uiState = MutableStateFlow(ThisOrThatUiState()) val uiState: StateFlow = _uiState.asStateFlow() - /** Active game-session handle, set once play begins, cleared when finished. */ - private var gameHandle: GameHandle? = null + private var userId: String? = null + private var partnerId: String? = null + private var coupleId: String? = null + private var sessionId: String? = null + private var observeJob: Job? = null + + /** True once this user's picks are written, so quitting won't cancel the shared session. */ + private var submitted = false init { - checkActiveSession() load() } - private fun checkActiveSession() { - viewModelScope.launch { - val userId = gameSessionManager.currentUserId ?: return@launch - val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch - if (gameSessionManager.hasActiveSession(couple.id)) { - _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } - } - } - } - private fun load() { viewModelScope.launch { - val questions = runCatching { - repository.getQuestionsByType("this_or_that").shuffled().take(SESSION_SIZE) - } - .onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) } - .getOrElse { emptyList() } + val uid = gameSessionManager.currentUserId + ?: return@launch fail("You need to be signed in to play.") + val couple = gameSessionManager.getCoupleForUser(uid) + ?: return@launch fail("Pair with your partner to play together.") - _uiState.update { - it.copy( - isLoading = false, - questions = questions, - error = if (questions.isEmpty()) "No questions available." else null - ) + userId = uid + coupleId = couple.id + partnerId = couple.userIds.firstOrNull { it != uid } + partnerId?.let { pid -> + runCatching { gameSessionManager.getUser(pid)?.displayName } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { name -> _uiState.update { s -> s.copy(partnerName = name) } } + } + + val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull() + when { + active != null && active.gameType == GameType.THIS_OR_THAT -> + joinSession(active.id, active.questionIds) + active != null -> + // A different game is already in progress — respect the one-game lock. + _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } + else -> + createSession(uid) } - // No intro screen — play begins immediately, so open the session now. - if (questions.isNotEmpty()) startSession() } } - private fun startSession() { - viewModelScope.launch { - gameSessionManager.startGameForCurrentUser(gameType = GameType.THIS_OR_THAT) - .onSuccess { gameHandle = it } - .onFailure { Log.w(TAG, "Could not start session", it) } + /** First partner: pick a fixed set of prompts and open the shared session. */ + private suspend fun createSession(uid: String) { + val picked = runCatching { repository.getQuestionsByType("this_or_that") } + .onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) } + .getOrElse { emptyList() } + .shuffled() + .take(SESSION_SIZE) + if (picked.isEmpty()) return fail("No questions available.") + + val startResult = runCatching { + gameSessionManager.startGame( + userId = uid, + gameType = GameType.THIS_OR_THAT, + questionIds = picked.map { it.id } + ) + }.getOrElse { Result.failure(it) } + + when { + startResult.isSuccess -> { + sessionId = startResult.getOrThrow() + _uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) } + observeReveal() + } + startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true -> + _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } + else -> { + Log.w(TAG, "Could not start session", startResult.exceptionOrNull()) + fail("Could not start the game.") + } } } - /** 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) } + /** Second partner: join the in-flight session with the exact same prompts, in the same order. */ + private suspend fun joinSession(existingSessionId: String, questionIds: List) { + sessionId = existingSessionId + val byId = runCatching { repository.getQuestionsByType("this_or_that") } + .getOrElse { emptyList() } + .associateBy { it.id } + val questions = questionIds.mapNotNull { byId[it] } + if (questions.isEmpty()) return fail("Could not load this game.") + _uiState.update { it.copy(phase = TotPhase.PLAYING, questions = questions) } + observeReveal() } - /** Finish any dangling session, then route back to the Play hub. */ - fun quit() { - viewModelScope.launch { - finishSession() - _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } + /** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */ + private fun observeReveal() { + val cId = coupleId ?: return + val sId = sessionId ?: return + observeJob?.cancel() + observeJob = viewModelScope.launch { + dataSource.observeAnswers(cId, sId).collect { answers -> + val mine = userId?.let { answers.byUser[it] } + val theirs = partnerId?.let { answers.byUser[it] } + when { + mine != null && theirs != null -> revealResult(mine, theirs) + mine != null -> _uiState.update { + if (it.phase == TotPhase.REVEAL) it else it.copy(phase = TotPhase.WAITING) + } + // else: I still haven't answered — stay on PLAYING. + } + } } } fun select(optionId: String) { val s = _uiState.value - if (s.pendingSelection != null || s.isComplete || s.isLoading) return - val config = s.questions.getOrNull(s.currentIndex) - ?.answerConfig as? ThisOrThatAnswerConfigImpl ?: return - val isA = config.config.optionA.id == optionId - _uiState.update { - it.copy( - pendingSelection = optionId, - aCount = if (isA) it.aCount + 1 else it.aCount, - bCount = if (!isA) it.bCount + 1 else it.bCount + if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return + _uiState.update { it.copy(pendingSelection = optionId) } + viewModelScope.launch { + delay(ADVANCE_DELAY_MS) + val answers = _uiState.value.myAnswers + optionId + val next = _uiState.value.currentIndex + 1 + if (next >= _uiState.value.questions.size) { + _uiState.update { + it.copy(pendingSelection = null, myAnswers = answers, phase = TotPhase.WAITING) + } + submitAnswers(answers) + } else { + _uiState.update { + it.copy(pendingSelection = null, currentIndex = next, myAnswers = answers) + } + } + } + } + + private suspend fun submitAnswers(answers: List) { + submitted = true + val cId = coupleId ?: return + val sId = sessionId ?: return + val uid = userId ?: return + runCatching { dataSource.submitAnswers(cId, sId, uid, answers) } + .onFailure { Log.w(TAG, "Could not submit answers", it) } + // The observer flips WAITING → REVEAL once the partner's answers land + // (or right away, if they finished first). + } + + private fun revealResult(mine: List, theirs: List) { + if (_uiState.value.phase == TotPhase.REVEAL) return + val questions = _uiState.value.questions + 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 = optionText(config, myOpt), + partnerText = optionText(config, theirOpt), + agreed = myOpt != null && myOpt == theirOpt ) } + _uiState.update { + it.copy( + phase = TotPhase.REVEAL, + revealCards = cards, + matchedCount = cards.count { c -> c.agreed } + ) + } + // Both have answered — release the one-game lock so a new game can start. + finishSession() + } + + private fun optionText(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 + } + + /** Mark the shared session completed (idempotent — fine if the partner already did). */ + private fun finishSession() { + val cId = coupleId ?: return + val sId = sessionId ?: return viewModelScope.launch { - delay(420) - val next = s.currentIndex + 1 - _uiState.update { - if (next >= it.questions.size) - it.copy(pendingSelection = null, isComplete = true) - else - it.copy(pendingSelection = null, currentIndex = next) - } - if (_uiState.value.isComplete) finishSession() + runCatching { gameSessionManager.finishGame(sId, cId) } + .onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") } + } + } + + fun quit() { + viewModelScope.launch { + // Bailed before submitting → cancel the session so the couple isn't locked out. + // After submitting → leave it active so the partner can still play and reveal. + if (!submitted) finishSession() + _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } } } fun restart() { + observeJob?.cancel() + sessionId = null + submitted = false _uiState.value = ThisOrThatUiState() load() } @@ -198,8 +319,13 @@ class ThisOrThatViewModel @Inject constructor( _uiState.update { it.copy(navigateTo = null) } } + private fun fail(message: String) { + _uiState.update { it.copy(phase = TotPhase.ERROR, error = message) } + } + companion object { const val SESSION_SIZE = 10 + private const val ADVANCE_DELAY_MS = 420L private const val TAG = "ThisOrThatViewModel" } } @@ -225,34 +351,46 @@ fun ThisOrThatScreen( .fillMaxSize() .background(closerBackgroundBrush()) ) { - when { - state.isLoading -> CircularProgressIndicator( + when (state.phase) { + TotPhase.LOADING -> CircularProgressIndicator( modifier = Modifier.align(Alignment.Center), color = CloserPalette.PurpleDeep ) - state.error != null -> ErrorState( - message = state.error!!, + TotPhase.ERROR -> ErrorState( + message = state.error ?: "Something went wrong.", onBack = viewModel::quit ) - state.isComplete -> ThisOrThatComplete( - aCount = state.aCount, - bCount = state.bCount, - total = state.questions.size, + TotPhase.WAITING -> WaitingForRevealScreen( + partnerName = state.partnerName, + onBack = viewModel::quit + ) + TotPhase.REVEAL -> ThisOrThatReveal( + matched = state.matchedCount, + total = state.revealCards.size, + partnerName = state.partnerName, + cards = state.revealCards, onPlayAgain = viewModel::restart, onHome = viewModel::quit ) - else -> { - val question = state.questions[state.currentIndex] - val config = question.answerConfig as? ThisOrThatAnswerConfigImpl - ThisOrThatContent( - question = question, - config = config, - currentIndex = state.currentIndex, - total = state.questions.size, - pendingSelection = state.pendingSelection, - onSelect = viewModel::select, - onBack = viewModel::quit - ) + TotPhase.PLAYING -> { + val question = state.questions.getOrNull(state.currentIndex) + if (question == null) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = CloserPalette.PurpleDeep + ) + } else { + val config = question.answerConfig as? ThisOrThatAnswerConfigImpl + ThisOrThatContent( + question = question, + config = config, + currentIndex = state.currentIndex, + total = state.questions.size, + pendingSelection = state.pendingSelection, + onSelect = viewModel::select, + onBack = viewModel::quit + ) + } } } } @@ -554,12 +692,9 @@ private fun VersusBadge( } @Composable -private fun ThisOrThatComplete( - aCount: Int, - bCount: Int, - total: Int, - onPlayAgain: () -> Unit, - onHome: () -> Unit +private fun WaitingForRevealScreen( + partnerName: String, + onBack: () -> Unit ) { Column( modifier = Modifier @@ -567,71 +702,45 @@ private fun ThisOrThatComplete( .safeDrawingPadding() .navigationBarsPadding() .padding(horizontal = 28.dp, vertical = 40.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(Modifier.weight(1f)) - ChoiceCompleteBadge() - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + Surface( + modifier = Modifier.size(104.dp), + shape = CircleShape, + color = CloserPalette.PinkMist, + shadowElevation = 8.dp ) { - Text( - text = "All done!", - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Text( - text = "You went through $total prompts.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - if (total > 0) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)), - elevation = CardDefaults.cardElevation(6.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - TallyItem(label = "A", count = aCount, color = CloserPalette.PurpleDeep) - VerticalDivider( - modifier = Modifier - .height(48.dp) - .width(1.dp), - color = Color(0xFFE8E0F0) - ) - TallyItem(label = "B", count = bCount, color = CloserPalette.PinkAccentDeep) - } + Box(contentAlignment = Alignment.Center) { + Text( + text = "✓", + style = MaterialTheme.typography.displaySmall, + color = CloserPalette.PurpleDeep + ) } } + Text( + text = "Your picks are in!", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Text( + text = "We'll reveal how you two compare as soon as $partnerName finishes.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(4.dp)) + CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp) + Spacer(Modifier.weight(1f)) - Button( - onClick = onPlayAgain, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp), - shape = RoundedCornerShape(18.dp), - colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep) - ) { - Text("Play again", color = Color.White) - } OutlinedButton( - onClick = onHome, + onClick = onBack, modifier = Modifier .fillMaxWidth() .heightIn(min = 56.dp), @@ -643,56 +752,201 @@ private fun ThisOrThatComplete( } @Composable -private fun ChoiceCompleteBadge() { - val infiniteTransition = rememberInfiniteTransition(label = "choice_complete") - val pulse by infiniteTransition.animateFloat( - initialValue = 0.96f, - targetValue = 1.04f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1300), - repeatMode = RepeatMode.Reverse - ), - label = "choice_complete_pulse" - ) +private fun ThisOrThatReveal( + matched: Int, + total: Int, + partnerName: String, + cards: List, + onPlayAgain: () -> Unit, + onHome: () -> Unit +) { + val ratio = if (total > 0) matched.toFloat() / total else 0f + val headline = when { + ratio >= 0.8f -> "Two peas in a pod 🫛" + ratio >= 0.5f -> "Lots in common 💛" + ratio > 0f -> "Opposites attract ✨" + else -> "Total opposites 😄" + } - Surface( + LazyColumn( modifier = Modifier - .size(104.dp) - .scale(pulse), - shape = CircleShape, - color = CloserPalette.PinkMist, - shadowElevation = 10.dp + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Box( - modifier = Modifier.background(CloserPalette.PurpleMist.copy(alpha = 0.42f)), - contentAlignment = Alignment.Center - ) { - Text( - text = "A/B", - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), - color = CloserPalette.PurpleDeep, - textAlign = TextAlign.Center - ) + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + MatchScoreBadge(matched = matched, total = total) + Text( + text = headline, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Text( + text = "You and $partnerName matched on $matched of $total.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(4.dp)) + } + } + + items(cards) { card -> + RevealRow(card = card, partnerName = partnerName) + } + + item { + Spacer(Modifier.height(8.dp)) + Button( + onClick = onPlayAgain, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep) + ) { + Text("Play again", color = Color.White) + } + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onHome, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + shape = RoundedCornerShape(18.dp) + ) { + Text("Back to Play") + } } } } @Composable -private fun TallyItem(label: String, count: Int, color: Color) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) +private fun MatchScoreBadge(matched: Int, total: Int) { + Surface( + modifier = Modifier.size(116.dp), + shape = CircleShape, + color = CloserPalette.PurpleMist, + shadowElevation = 8.dp ) { - Text( - text = "$count", - style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), - color = color - ) - Text( - text = "picked $label", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$matched/$total", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = CloserPalette.PurpleDeep + ) + Text( + text = "in sync", + style = MaterialTheme.typography.labelMedium, + color = CloserPalette.PurpleDeep.copy(alpha = 0.8f) + ) + } + } + } +} + +@Composable +private fun RevealRow(card: RevealCard, partnerName: String) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)), + elevation = CardDefaults.cardElevation(3.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = card.questionText, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(999.dp), + color = if (card.agreed) CloserPalette.Evergreen.copy(alpha = 0.15f) + else CloserPalette.PinkMist + ) { + Text( + text = if (card.agreed) "Match" else "Differ", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (card.agreed) CloserPalette.Evergreen else CloserPalette.PinkAccentDeep, + fontWeight = FontWeight.SemiBold + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + PickChip( + label = "You", + text = card.myText, + accent = CloserPalette.PurpleDeep, + modifier = Modifier.weight(1f) + ) + PickChip( + label = partnerName, + text = card.partnerText, + accent = CloserPalette.PinkAccentDeep, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun PickChip( + label: String, + text: String, + accent: Color, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = accent.copy(alpha = 0.08f) + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = accent, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } }