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 CHALLENGES = "challenges"
const val CAPSULES = "capsules"
const val THIS_OR_THAT = "this_or_that"
}
// ── 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.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<Question> = emptyList(),
val currentIndex: Int = 0,
val pendingSelection: String? = null,
val aCount: Int = 0,
val bCount: Int = 0,
val isComplete: Boolean = false,
val myAnswers: List<String> = emptyList(),
val partnerName: String = "Your partner",
val matchedCount: Int = 0,
val revealCards: List<RevealCard> = 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<ThisOrThatUiState> = _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<String>) {
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<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
)
}
_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<RevealCard>,
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
)
}
}
}