feat: add ThisOrThat Firestore data source, updated screen with question answering flow

This commit is contained in:
null 2026-06-18 19:53:17 -05:00
parent af06cb2123
commit 408a2f24ba
3 changed files with 507 additions and 188 deletions

View File

@ -32,6 +32,7 @@ object FirestoreCollections {
const val DAILY_QUESTION = "daily_question" const val DAILY_QUESTION = "daily_question"
const val CHALLENGES = "challenges" const val CHALLENGES = "challenges"
const val CAPSULES = "capsules" const val CAPSULES = "capsules"
const val THIS_OR_THAT = "this_or_that"
} }
// ── Subcollections under couples/{coupleId}/daily_question/{date} ─────────── // ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────

View File

@ -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<String, List<String>> = 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<String>
) {
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<ThisOrThatAnswers> =
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<String, *>
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
}.toMap()
trySend(ThisOrThatAnswers(byUser))
}
awaitClose { reg.remove() }
}
}

View File

@ -23,10 +23,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -39,7 +42,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -60,17 +62,18 @@ import androidx.hilt.navigation.compose.hiltViewModel
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
import app.closer.data.remote.FirestoreThisOrThatDataSource
import app.closer.domain.model.ChoiceOption import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.ThisOrThatAnswerConfig import app.closer.domain.model.ThisOrThatAnswerConfig
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -80,14 +83,25 @@ import kotlinx.coroutines.launch
// ── ViewModel ──────────────────────────────────────────────────────────────── // ── 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( data class ThisOrThatUiState(
val isLoading: Boolean = true, val phase: TotPhase = TotPhase.LOADING,
val questions: List<Question> = emptyList(), val questions: List<Question> = emptyList(),
val currentIndex: Int = 0, val currentIndex: Int = 0,
val pendingSelection: String? = null, val pendingSelection: String? = null,
val aCount: Int = 0, val myAnswers: List<String> = emptyList(),
val bCount: Int = 0, val partnerName: String = "Your partner",
val isComplete: Boolean = false, val matchedCount: Int = 0,
val revealCards: List<RevealCard> = emptyList(),
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -95,101 +109,208 @@ data class ThisOrThatUiState(
@HiltViewModel @HiltViewModel
class ThisOrThatViewModel @Inject constructor( class ThisOrThatViewModel @Inject constructor(
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreThisOrThatDataSource
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ThisOrThatUiState()) private val _uiState = MutableStateFlow(ThisOrThatUiState())
val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow() val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow()
/** Active game-session handle, set once play begins, cleared when finished. */ private var userId: String? = null
private var gameHandle: GameHandle? = 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 { init {
checkActiveSession()
load() 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() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val questions = runCatching { val uid = gameSessionManager.currentUserId
repository.getQuestionsByType("this_or_that").shuffled().take(SESSION_SIZE) ?: 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.")
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)
}
}
}
/** 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) } .onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) }
.getOrElse { emptyList() } .getOrElse { emptyList() }
.shuffled()
.take(SESSION_SIZE)
if (picked.isEmpty()) return fail("No questions available.")
_uiState.update { val startResult = runCatching {
it.copy( gameSessionManager.startGame(
isLoading = false, userId = uid,
questions = questions, gameType = GameType.THIS_OR_THAT,
error = if (questions.isEmpty()) "No questions available." else null 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.")
} }
// No intro screen — play begins immediately, so open the session now.
if (questions.isNotEmpty()) startSession()
} }
} }
private fun startSession() { /** Second partner: join the in-flight session with the exact same prompts, in the same order. */
viewModelScope.launch { private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
gameSessionManager.startGameForCurrentUser(gameType = GameType.THIS_OR_THAT) sessionId = existingSessionId
.onSuccess { gameHandle = it } val byId = runCatching { repository.getQuestionsByType("this_or_that") }
.onFailure { Log.w(TAG, "Could not start session", it) } .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()
} }
/** Marks the active session completed (idempotent — no-op if already finished). */ /** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */
private suspend fun finishSession() { private fun observeReveal() {
val handle = gameHandle ?: return val cId = coupleId ?: return
gameHandle = null val sId = sessionId ?: return
gameSessionManager.finishGameForCurrentUser(handle) observeJob?.cancel()
.onFailure { Log.w(TAG, "Could not finish session", it) } 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.
}
} }
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
} }
} }
fun select(optionId: String) { fun select(optionId: String) {
val s = _uiState.value val s = _uiState.value
if (s.pendingSelection != null || s.isComplete || s.isLoading) return if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return
val config = s.questions.getOrNull(s.currentIndex) _uiState.update { it.copy(pendingSelection = optionId) }
?.answerConfig as? ThisOrThatAnswerConfigImpl ?: return viewModelScope.launch {
val isA = config.config.optionA.id == optionId delay(ADVANCE_DELAY_MS)
val answers = _uiState.value.myAnswers + optionId
val next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.questions.size) {
_uiState.update { _uiState.update {
it.copy( it.copy(pendingSelection = null, myAnswers = answers, phase = TotPhase.WAITING)
pendingSelection = optionId, }
aCount = if (isA) it.aCount + 1 else it.aCount, submitAnswers(answers)
bCount = if (!isA) it.bCount + 1 else it.bCount } else {
_uiState.update {
it.copy(pendingSelection = null, currentIndex = next, myAnswers = answers)
}
}
}
}
private suspend fun submitAnswers(answers: List<String>) {
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<String>, theirs: List<String>) {
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
) )
} }
viewModelScope.launch {
delay(420)
val next = s.currentIndex + 1
_uiState.update { _uiState.update {
if (next >= it.questions.size) it.copy(
it.copy(pendingSelection = null, isComplete = true) phase = TotPhase.REVEAL,
else revealCards = cards,
it.copy(pendingSelection = null, currentIndex = next) matchedCount = cards.count { c -> c.agreed }
)
} }
if (_uiState.value.isComplete) finishSession() // 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 {
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() { fun restart() {
observeJob?.cancel()
sessionId = null
submitted = false
_uiState.value = ThisOrThatUiState() _uiState.value = ThisOrThatUiState()
load() load()
} }
@ -198,8 +319,13 @@ class ThisOrThatViewModel @Inject constructor(
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }
private fun fail(message: String) {
_uiState.update { it.copy(phase = TotPhase.ERROR, error = message) }
}
companion object { companion object {
const val SESSION_SIZE = 10 const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 420L
private const val TAG = "ThisOrThatViewModel" private const val TAG = "ThisOrThatViewModel"
} }
} }
@ -225,24 +351,35 @@ fun ThisOrThatScreen(
.fillMaxSize() .fillMaxSize()
.background(closerBackgroundBrush()) .background(closerBackgroundBrush())
) { ) {
when { when (state.phase) {
state.isLoading -> CircularProgressIndicator( TotPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep color = CloserPalette.PurpleDeep
) )
state.error != null -> ErrorState( TotPhase.ERROR -> ErrorState(
message = state.error!!, message = state.error ?: "Something went wrong.",
onBack = viewModel::quit onBack = viewModel::quit
) )
state.isComplete -> ThisOrThatComplete( TotPhase.WAITING -> WaitingForRevealScreen(
aCount = state.aCount, partnerName = state.partnerName,
bCount = state.bCount, onBack = viewModel::quit
total = state.questions.size, )
TotPhase.REVEAL -> ThisOrThatReveal(
matched = state.matchedCount,
total = state.revealCards.size,
partnerName = state.partnerName,
cards = state.revealCards,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = viewModel::quit onHome = viewModel::quit
) )
else -> { TotPhase.PLAYING -> {
val question = state.questions[state.currentIndex] 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 val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
ThisOrThatContent( ThisOrThatContent(
question = question, question = question,
@ -257,6 +394,7 @@ fun ThisOrThatScreen(
} }
} }
} }
}
@Composable @Composable
private fun ThisOrThatContent( private fun ThisOrThatContent(
@ -554,12 +692,9 @@ private fun VersusBadge(
} }
@Composable @Composable
private fun ThisOrThatComplete( private fun WaitingForRevealScreen(
aCount: Int, partnerName: String,
bCount: Int, onBack: () -> Unit
total: Int,
onPlayAgain: () -> Unit,
onHome: () -> Unit
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -567,71 +702,45 @@ private fun ThisOrThatComplete(
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp), .padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
ChoiceCompleteBadge() Surface(
modifier = Modifier.size(104.dp),
Column( shape = CircleShape,
horizontalAlignment = Alignment.CenterHorizontally, color = CloserPalette.PinkMist,
verticalArrangement = Arrangement.spacedBy(8.dp) shadowElevation = 8.dp
) { ) {
Box(contentAlignment = Alignment.Center) {
Text( Text(
text = "All done!", text = "",
style = MaterialTheme.typography.displaySmall,
color = CloserPalette.PurpleDeep
)
}
}
Text(
text = "Your picks are in!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( Text(
text = "You went through $total prompts.", text = "We'll reveal how you two compare as soon as $partnerName finishes.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
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)
}
}
}
Spacer(Modifier.weight(1f)) 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( OutlinedButton(
onClick = onHome, onClick = onBack,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 56.dp), .heightIn(min = 56.dp),
@ -643,58 +752,203 @@ private fun ThisOrThatComplete(
} }
@Composable @Composable
private fun ChoiceCompleteBadge() { private fun ThisOrThatReveal(
val infiniteTransition = rememberInfiniteTransition(label = "choice_complete") matched: Int,
val pulse by infiniteTransition.animateFloat( total: Int,
initialValue = 0.96f, partnerName: String,
targetValue = 1.04f, cards: List<RevealCard>,
animationSpec = infiniteRepeatable( onPlayAgain: () -> Unit,
animation = tween(durationMillis = 1300), onHome: () -> Unit
repeatMode = RepeatMode.Reverse ) {
), val ratio = if (total > 0) matched.toFloat() / total else 0f
label = "choice_complete_pulse" 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 modifier = Modifier
.size(104.dp) .fillMaxSize()
.scale(pulse), .safeDrawingPadding()
shape = CircleShape, .navigationBarsPadding(),
color = CloserPalette.PinkMist, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
shadowElevation = 10.dp verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box( item {
modifier = Modifier.background(CloserPalette.PurpleMist.copy(alpha = 0.42f)), Column(
contentAlignment = Alignment.Center modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
MatchScoreBadge(matched = matched, total = total)
Text( Text(
text = "A/B", text = headline,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.PurpleDeep, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center 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 @Composable
private fun TallyItem(label: String, count: Int, color: Color) { private fun MatchScoreBadge(matched: Int, total: Int) {
Surface(
modifier = Modifier.size(116.dp),
shape = CircleShape,
color = CloserPalette.PurpleMist,
shadowElevation = 8.dp
) {
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( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
verticalArrangement = Arrangement.spacedBy(4.dp) .fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "$count", text = card.questionText,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = color 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 = "picked $label", text = text,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
} }
}
@Composable @Composable
private fun ErrorState(message: String, onBack: () -> Unit) { private fun ErrorState(message: String, onBack: () -> Unit) {