feat: add DesireSync + HowWell Firestore data sources, update screens with cloud-backed answering

This commit is contained in:
null 2026-06-18 20:47:18 -05:00
parent 97cc334136
commit 473feb78a9
5 changed files with 672 additions and 554 deletions

View File

@ -34,6 +34,8 @@ object FirestoreCollections {
const val CAPSULES = "capsules"
const val THIS_OR_THAT = "this_or_that"
const val WHEEL = "wheel"
const val DESIRE_SYNC = "desire_sync"
const val HOW_WELL = "how_well"
}
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────

View File

@ -0,0 +1,70 @@
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' yes/no picks for one Desire Sync session, keyed by userId. */
data class DesireSyncAnswers(
val byUser: Map<String, List<String>> = emptyMap()
)
/**
* Stores each partner's private yes/no answers for the async Desire Sync reveal at
* `couples/{coupleId}/desire_sync/{sessionId}`. The session (the shared topic set +
* the one-active-game lock) is owned by [app.closer.domain.usecase.GameSessionManager];
* this only carries the answers so each partner plays privately on their own device
* and only mutually-wanted topics surface once both have submitted.
*/
@Singleton
class FirestoreDesireSyncDataSource @Inject constructor(
private val db: FirebaseFirestore
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.DESIRE_SYNC)
.document(sessionId)
/** Persist this user's picks (optionIds aligned to the session's topic 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()
}
/** One-shot read — used to detect whether this user has already answered. */
suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? =
runCatching {
val snap = doc(coupleId, sessionId).get().await()
DesireSyncAnswers(parseAnswers(snap.get("answers")))
}.getOrNull()
/** Live view of both partners' picks; emits whenever either side submits. */
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"))))
}
awaitClose { reg.remove() }
}
private fun parseAnswers(raw: Any?): Map<String, List<String>> {
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
}.toMap()
}
}

View File

@ -0,0 +1,83 @@
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
/** One answer in a How Well session — either a chosen option or a scale value. */
data class HowWellRawAnswer(val optionId: String? = null, val scale: Int? = null)
/** Both partners' answers for one How Well session, keyed by userId. */
data class HowWellAnswers(
val byUser: Map<String, List<HowWellRawAnswer>> = emptyMap()
)
/**
* Stores each partner's answers for the async How Well reveal at
* `couples/{coupleId}/how_well/{sessionId}`. The subject (session starter) answers
* honestly; the guesser predicts. The session (shared question set + the
* one-active-game lock) is owned by [app.closer.domain.usecase.GameSessionManager];
* this carries the answers so each partner plays on their own device and the score
* reveals once both have submitted.
*/
@Singleton
class FirestoreHowWellDataSource @Inject constructor(
private val db: FirebaseFirestore
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.HOW_WELL)
.document(sessionId)
/** Persist this user's full answer list (aligned to the session's question order). */
suspend fun submitAnswers(
coupleId: String,
sessionId: String,
userId: String,
answers: List<HowWellRawAnswer>
) {
val payload = answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to payload)), SetOptions.merge())
.await()
}
/** One-shot read — used to detect whether this user has already answered. */
suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? =
runCatching {
HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers")))
}.getOrNull()
/** Live view of both partners' answers; emits whenever either side submits. */
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(HowWellAnswers(parseAnswers(snap.get("answers"))))
}
awaitClose { reg.remove() }
}
private fun parseAnswers(raw: Any?): Map<String, List<HowWellRawAnswer>> {
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
(value as? List<*>)?.let { list ->
uid to list.mapNotNull { item ->
(item as? Map<*, *>)?.let {
HowWellRawAnswer(
optionId = it["optionId"] as? String,
scale = (it["scale"] as? Number)?.toInt()
)
}
}
}
}.toMap()
}
}

View File

@ -28,7 +28,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@ -54,16 +53,17 @@ 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.FirestoreDesireSyncDataSource
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.StatusGlyph
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
@ -84,18 +84,16 @@ data class DesireMatch(
val label: String // human-friendly topic label
)
enum class DesireSyncPhase {
LOADING, PARTNER_A_INTRO, PARTNER_A_TURN, HANDOFF,
PARTNER_B_INTRO, PARTNER_B_TURN, REVEAL
}
enum class DesireSyncPhase { LOADING, INTRO, ANSWER, WAITING, REVEAL, ERROR }
data class DesireSyncUiState(
val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
val pairs: List<DesirePair> = emptyList(),
val currentIndex: Int = 0,
val partnerAAnswers: List<String> = emptyList(),
val partnerBAnswers: List<String> = emptyList(),
val pendingSelection: String? = null,
val myAnswers: List<String> = emptyList(),
val amStarter: Boolean = true,
val partnerName: String = "Your partner",
val matches: List<DesireMatch> = emptyList(),
val error: String? = null,
val navigateTo: String? = null
@ -120,174 +118,230 @@ private fun topicLabel(femaleQ: Question): String =
@HiltViewModel
class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
val uiState: StateFlow<DesireSyncUiState> = _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 startSession() {
viewModelScope.launch {
gameSessionManager.startGameForCurrentUser(gameType = GameType.DESIRE_SYNC)
.onSuccess { gameHandle = it }
.onFailure { Log.w(TAG, "Could not start session", it) }
}
}
/** 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) }
}
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
private fun load() {
viewModelScope.launch {
val female = runCatching { repository.getDesireSyncQuestions("female") }
.onFailure { Log.w(TAG, "load female failed", it) }
.getOrElse { emptyList() }
val male = runCatching { repository.getDesireSyncQuestions("male") }
.onFailure { Log.w(TAG, "load male failed", 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.")
val filteredFemale = female.filter { it.sex == "female" }
val filteredMale = male.filter { it.sex == "male" }
val maleById = filteredMale.associateBy { it.id.replace("_male_", "_") }
val pairs = filteredFemale
.filter { isBinaryQuestion(it) }
.shuffled()
.take(SESSION_SIZE)
.mapNotNull { fq ->
val key = fq.id.replace("_female_", "_")
maleById[key]?.let { mq -> DesirePair(fq, mq) }
}
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) } }
}
_uiState.update {
it.copy(
phase = if (pairs.isEmpty()) DesireSyncPhase.LOADING else DesireSyncPhase.PARTNER_A_INTRO,
pairs = pairs,
error = if (pairs.isEmpty()) "No questions available." else null
)
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
when {
active != null && active.gameType == GameType.DESIRE_SYNC ->
joinSession(uid, active.id, active.startedByUserId, 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)
}
}
}
fun startPartnerA() {
startSession()
_uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
/** First partner: pick the topic set, open the shared session, answer their own side. */
private suspend fun createSession(uid: String) {
val pairs = buildPairs(loadFemale(), loadMale()).shuffled().take(SESSION_SIZE)
if (pairs.isEmpty()) return fail("No questions available.")
val startResult = runCatching {
gameSessionManager.startGame(
userId = uid,
gameType = GameType.DESIRE_SYNC,
questionIds = pairs.map { it.femaleQ.id }
)
}.getOrElse { Result.failure(it) }
when {
startResult.isSuccess -> {
sessionId = startResult.getOrThrow()
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, pairs = pairs, amStarter = true) }
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.")
}
}
}
/** Second partner: join the in-flight session and rebuild the identical topic set. */
private suspend fun joinSession(
uid: String,
existingSessionId: String,
startedByUserId: String,
femaleIds: List<String>
) {
sessionId = existingSessionId
val amStarter = startedByUserId == uid
val femaleById = loadFemale().associateBy { it.id }
val maleByKey = loadMale().associateBy { it.id.replace("_male_", "_") }
val pairs = femaleIds.mapNotNull { fid ->
val fq = femaleById[fid] ?: return@mapNotNull null
val mq = maleByKey[fq.id.replace("_female_", "_")] ?: return@mapNotNull null
DesirePair(fq, mq)
}
if (pairs.isEmpty()) return fail("Could not load this game.")
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, pairs = pairs, amStarter = amStarter) }
observeReveal()
}
private suspend fun loadFemale(): List<Question> =
runCatching { repository.getDesireSyncQuestions("female") }
.onFailure { Log.w(TAG, "load female failed", it) }
.getOrElse { emptyList() }
.filter { it.sex == "female" }
private suspend fun loadMale(): List<Question> =
runCatching { repository.getDesireSyncQuestions("male") }
.onFailure { Log.w(TAG, "load male failed", it) }
.getOrElse { emptyList() }
.filter { it.sex == "male" }
private fun buildPairs(female: List<Question>, male: List<Question>): List<DesirePair> {
val maleByKey = male.associateBy { it.id.replace("_male_", "_") }
return female.filter { isBinaryQuestion(it) }.mapNotNull { fq ->
maleByKey[fq.id.replace("_female_", "_")]?.let { DesirePair(fq, it) }
}
}
fun startAnswering() {
_uiState.update { it.copy(phase = DesireSyncPhase.ANSWER, currentIndex = 0) }
}
fun select(optionId: String) {
val s = _uiState.value
if (s.pendingSelection != null) return
if (s.pendingSelection != null || s.phase != DesireSyncPhase.ANSWER) return
_uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch {
delay(ADVANCE_DELAY_MS)
_uiState.update { state ->
val newAnswers = state.partnerAAnswers + optionId
val next = state.currentIndex + 1
if (next >= state.pairs.size) {
state.copy(
partnerAAnswers = newAnswers,
pendingSelection = null,
phase = DesireSyncPhase.HANDOFF
)
} else {
state.copy(
partnerAAnswers = newAnswers,
pendingSelection = null,
currentIndex = next
)
val answers = _uiState.value.myAnswers + optionId
val next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.pairs.size) {
_uiState.update {
it.copy(pendingSelection = null, myAnswers = answers, phase = DesireSyncPhase.WAITING)
}
submitAnswers(answers)
} else {
_uiState.update {
it.copy(pendingSelection = null, currentIndex = next, myAnswers = answers)
}
}
}
}
fun selectB(optionId: String) {
val s = _uiState.value
if (s.pendingSelection != null) return
_uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch {
delay(ADVANCE_DELAY_MS)
_uiState.update { state ->
val newAnswers = state.partnerBAnswers + optionId
val next = state.currentIndex + 1
if (next >= state.pairs.size) {
val matches = computeMatches(state.pairs, state.partnerAAnswers, newAnswers)
state.copy(
partnerBAnswers = newAnswers,
pendingSelection = null,
matches = matches,
phase = DesireSyncPhase.REVEAL
)
} else {
state.copy(
partnerBAnswers = newAnswers,
pendingSelection = null,
currentIndex = next
)
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.
}
/** 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 == DesireSyncPhase.REVEAL) it
else it.copy(phase = DesireSyncPhase.WAITING)
}
// else: I haven't answered yet — stay on INTRO/ANSWER.
}
}
if (_uiState.value.phase == DesireSyncPhase.REVEAL) {
finishSession()
}
}
}
fun startPartnerB() = _uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_B_TURN, currentIndex = 0, pendingSelection = null)
private fun revealResult(mine: List<String>, theirs: List<String>) {
if (_uiState.value.phase == DesireSyncPhase.REVEAL) return
val pairs = _uiState.value.pairs
val matches = pairs.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(pairs[i].femaleQ, pairs[i].maleQ, topicLabel(pairs[i].femaleQ))
} else null
}
_uiState.update { it.copy(phase = DesireSyncPhase.REVEAL, matches = matches) }
// Both have answered — release the one-game lock so a new game can start.
finishSession()
}
/** 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() {
observeJob?.cancel()
sessionId = null
submitted = false
_uiState.value = DesireSyncUiState()
load()
}
private fun computeMatches(
pairs: List<DesirePair>,
aAnswers: List<String>,
bAnswers: List<String>
): List<DesireMatch> = pairs.indices.mapNotNull { i ->
val a = aAnswers.getOrNull(i)?.lowercase() ?: return@mapNotNull null
val b = bAnswers.getOrNull(i)?.lowercase() ?: return@mapNotNull null
if (a in POSITIVE_IDS && b in POSITIVE_IDS) {
DesireMatch(pairs[i].femaleQ, pairs[i].maleQ, topicLabel(pairs[i].femaleQ))
} else null
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
private fun fail(message: String) {
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = message) }
}
companion object {
private const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 380L
@ -321,15 +375,18 @@ fun DesireSyncScreen(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.Romantic
)
DesireSyncPhase.PARTNER_A_INTRO -> DSIntroScreen(
playerNumber = 1,
total = state.pairs.size,
onReady = viewModel::startPartnerA
DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something went wrong.",
onBack = viewModel::quit
)
DesireSyncPhase.PARTNER_A_TURN -> {
DesireSyncPhase.INTRO -> DSIntroScreen(
total = state.pairs.size,
onReady = viewModel::startAnswering
)
DesireSyncPhase.ANSWER -> {
val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
DSAnswerScreen(
question = pair.femaleQ,
question = if (state.amStarter) pair.femaleQ else pair.maleQ,
index = state.currentIndex,
total = state.pairs.size,
pendingSelection = state.pendingSelection,
@ -337,26 +394,14 @@ fun DesireSyncScreen(
onQuit = viewModel::quit
)
}
DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB)
DesireSyncPhase.PARTNER_B_INTRO -> DSIntroScreen(
playerNumber = 2,
total = state.pairs.size,
onReady = viewModel::startPartnerB
DesireSyncPhase.WAITING -> DSWaitingScreen(
partnerName = state.partnerName,
onBack = viewModel::quit
)
DesireSyncPhase.PARTNER_B_TURN -> {
val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
DSAnswerScreen(
question = pair.maleQ,
index = state.currentIndex,
total = state.pairs.size,
pendingSelection = state.pendingSelection,
onSelect = viewModel::selectB,
onQuit = viewModel::quit
)
}
DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches,
total = state.pairs.size,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
)
@ -367,7 +412,7 @@ fun DesireSyncScreen(
// ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@ -378,33 +423,20 @@ private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusGlyph(
icon = if (playerNumber == 1) Icons.Filled.FavoriteBorder else Icons.Filled.Visibility,
icon = Icons.Filled.FavoriteBorder,
tint = CloserPalette.Romantic,
container = CloserPalette.Romantic.copy(alpha = 0.12f)
)
Spacer(Modifier.height(20.dp))
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.Romantic.copy(alpha = 0.14f)) {
Text(
text = "Partner ${if (playerNumber == 1) "A" else "B"}",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.Romantic,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(16.dp))
Text(
text = if (playerNumber == 1)
"Answer $total questions honestly — just tap Yes or No.\nYour answers are private until the reveal."
else
"Your turn. Same questions, your side.\nYour answers are private until the reveal.",
text = "Answer $total questions privately — just tap Yes or No.",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = "Only things you both want will be shown.",
text = "Your partner answers on their own device. Only the things you both want will ever be shown — everything else stays private.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@ -420,42 +452,66 @@ private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
}
@Composable
private fun DSHandoffScreen(onReady: () -> Unit) {
private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
StatusGlyph(
icon = Icons.Filled.Sync,
icon = Icons.Filled.Favorite,
tint = CloserPalette.Romantic,
container = CloserPalette.Romantic.copy(alpha = 0.12f)
)
Spacer(Modifier.height(20.dp))
Text(
text = "Pass the phone!",
text = "Your answers are in!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
text = "Partner A is done. Hand the phone to Partner B — keep your answers secret until the reveal.",
text = "Nothing is shared yet. The moment $partnerName finishes, we'll reveal only what you both said yes to.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)
) { Text("I'm Partner B, let's go!", color = Color.White) }
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
}
}
@Composable
private fun DSErrorScreen(message: String, onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
Text("Back to Play")
}
}
}
@ -567,6 +623,7 @@ private fun DSAnswerScreen(
private fun DSRevealScreen(
matches: List<DesireMatch>,
total: Int,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
) {
@ -605,7 +662,7 @@ private fun DSRevealScreen(
textAlign = TextAlign.Center
)
}
DesireRevealMeter(matches = matches.size, total = total)
DesireRevealMeter(matches = matches.size, total = total, partnerName = partnerName)
}
}
@ -685,7 +742,8 @@ private fun DesireProgressPill(
@Composable
private fun DesireRevealMeter(
matches: Int,
total: Int
total: Int,
partnerName: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
@ -703,7 +761,7 @@ private fun DesireRevealMeter(
verticalAlignment = Alignment.CenterVertically
) {
DesirePrivacyTile(
label = "Partner A",
label = "You",
value = "$total private",
modifier = Modifier.weight(1f)
)
@ -715,7 +773,7 @@ private fun DesireRevealMeter(
iconSize = 20.dp
)
DesirePrivacyTile(
label = "Partner B",
label = partnerName,
value = "$total private",
modifier = Modifier.weight(1f)
)

View File

@ -63,7 +63,9 @@ import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.data.remote.FirestoreHowWellDataSource
import app.closer.data.remote.HowWellAnswers
import app.closer.data.remote.HowWellRawAnswer
import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph
@ -72,6 +74,7 @@ import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -83,9 +86,7 @@ import kotlinx.coroutines.launch
data class HowWellAnswer(
val selectedOptionId: String? = null,
val scaleValue: Int? = null
) {
val isEmpty get() = selectedOptionId == null && scaleValue == null
}
)
fun HowWellAnswer.isMatch(other: HowWellAnswer): Boolean = when {
selectedOptionId != null -> selectedOptionId == other.selectedOptionId
@ -119,19 +120,18 @@ data class HowWellResult(
val isClose: Boolean
)
enum class HowWellPhase {
LOADING, PLAYER_A_INTRO, PLAYER_A_TURN, HANDOFF,
PLAYER_B_TURN, REVEALING, COMPLETE
}
enum class HowWellPhase { LOADING, INTRO, ANSWER, WAITING, COMPLETE, ERROR }
data class HowWellUiState(
val phase: HowWellPhase = HowWellPhase.LOADING,
val questions: List<Question> = emptyList(),
val currentIndex: Int = 0,
val playerAAnswers: List<HowWellAnswer> = emptyList(),
val results: List<HowWellResult> = emptyList(),
val selectedOptionId: String? = null,
val selectedScale: Int? = null,
/** True for the session starter (answers honestly); false for the guesser (predicts). */
val amSubject: Boolean = true,
val partnerName: String = "Your partner",
val results: List<HowWellResult> = emptyList(),
val score: Int = 0,
val error: String? = null,
val navigateTo: String? = null
@ -142,134 +142,210 @@ data class HowWellUiState(
@HiltViewModel
class HowWellViewModel @Inject constructor(
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(HowWellUiState())
val uiState: StateFlow<HowWellUiState> = _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 startedByUserId: String? = null
private val myAnswers = mutableListOf<HowWellRawAnswer>()
private var observeJob: Job? = null
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 startSession() {
viewModelScope.launch {
gameSessionManager.startGameForCurrentUser(gameType = GameType.HOW_WELL)
.onSuccess { gameHandle = it }
.onFailure { Log.w(TAG, "Could not start session", it) }
}
}
/** 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) }
}
/** Finish any dangling session, then route back to the Play hub. */
fun quit() {
viewModelScope.launch {
finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
private fun load() {
viewModelScope.launch {
val questions = runCatching {
repository.getQuestionsForPrediction().shuffled().take(SESSION_SIZE)
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.")
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 { it.copy(partnerName = name) } }
}
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() }
_uiState.update {
it.copy(
phase = if (questions.isEmpty()) HowWellPhase.LOADING else HowWellPhase.PLAYER_A_INTRO,
questions = questions,
error = if (questions.isEmpty()) "No questions available." else null
)
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
when {
active != null && active.gameType == GameType.HOW_WELL ->
joinSession(uid, active.id, active.startedByUserId, 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 becomes the subject: they answer about themselves. */
private suspend fun createSession(uid: String) {
val questions = runCatching { repository.getQuestionsForPrediction() }
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() }
.shuffled()
.take(SESSION_SIZE)
if (questions.isEmpty()) return fail("No questions available.")
val startResult = runCatching {
gameSessionManager.startGame(
userId = uid,
gameType = GameType.HOW_WELL,
questionIds = questions.map { it.id }
)
}.getOrElse { Result.failure(it) }
when {
startResult.isSuccess -> {
sessionId = startResult.getOrThrow()
startedByUserId = uid
_uiState.update {
it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = true)
}
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.")
}
}
}
/** Second partner becomes the guesser: same questions, predicting the subject. */
private suspend fun joinSession(
uid: String,
existingSessionId: String,
startedBy: String,
questionIds: List<String>
) {
sessionId = existingSessionId
startedByUserId = startedBy
val questions = questionIds.mapNotNull { repository.getQuestionById(it) }
if (questions.isEmpty()) return fail("Could not load this game.")
_uiState.update {
it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = startedBy == uid)
}
observeReveal()
}
fun selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) }
fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) }
fun startPlayerA() {
startSession()
_uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) }
fun startAnswering() {
_uiState.update {
it.copy(phase = HowWellPhase.ANSWER, currentIndex = 0, selectedOptionId = null, selectedScale = null)
}
}
fun confirmAnswer() {
fun confirm() {
val s = _uiState.value
val answer = HowWellAnswer(s.selectedOptionId, s.selectedScale)
if (answer.isEmpty) return
val newAnswers = s.playerAAnswers + answer
if (s.phase != HowWellPhase.ANSWER) return
if (s.selectedOptionId == null && s.selectedScale == null) return
myAnswers.add(HowWellRawAnswer(s.selectedOptionId, s.selectedScale))
val next = s.currentIndex + 1
_uiState.update {
it.copy(
playerAAnswers = newAnswers,
selectedOptionId = null,
selectedScale = null,
currentIndex = if (next < it.questions.size) next else it.currentIndex,
phase = if (next >= it.questions.size) HowWellPhase.HANDOFF else HowWellPhase.PLAYER_A_TURN
)
if (next >= s.questions.size) {
_uiState.update {
it.copy(selectedOptionId = null, selectedScale = null, phase = HowWellPhase.WAITING)
}
viewModelScope.launch { submitAnswers() }
} else {
_uiState.update { it.copy(currentIndex = next, selectedOptionId = null, selectedScale = null) }
}
}
fun readyForPlayerB() = _uiState.update {
it.copy(phase = HowWellPhase.PLAYER_B_TURN, currentIndex = 0, selectedOptionId = null, selectedScale = null)
private suspend fun submitAnswers() {
submitted = true
val cId = coupleId ?: return
val sId = sessionId ?: return
val uid = userId ?: return
runCatching { dataSource.submitAnswers(cId, sId, uid, myAnswers.toList()) }
.onFailure { Log.w(TAG, "Could not submit answers", it) }
// The observer flips WAITING → COMPLETE once the partner's answers land.
}
fun confirmPrediction() {
val s = _uiState.value
val prediction = HowWellAnswer(s.selectedOptionId, s.selectedScale)
if (prediction.isEmpty) return
val actual = s.playerAAnswers.getOrNull(s.currentIndex) ?: return
val question = s.questions.getOrNull(s.currentIndex) ?: return
val match = prediction.isMatch(actual)
val close = prediction.isClose(actual)
_uiState.update {
it.copy(
results = it.results + HowWellResult(question, actual, prediction, match, close),
selectedOptionId = null,
selectedScale = null,
score = if (match) it.score + 1 else it.score,
phase = HowWellPhase.REVEALING
)
/** Single source of truth for WAITING/COMPLETE: 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.isNullOrEmpty() && !theirs.isNullOrEmpty() -> revealResult(answers)
!mine.isNullOrEmpty() -> _uiState.update {
if (it.phase == HowWellPhase.COMPLETE) it else it.copy(phase = HowWellPhase.WAITING)
}
// else: I haven't answered yet — stay on INTRO/ANSWER.
}
}
}
}
fun nextQuestion() {
val next = _uiState.value.currentIndex + 1
val total = _uiState.value.questions.size
private fun revealResult(answers: HowWellAnswers) {
if (_uiState.value.phase == HowWellPhase.COMPLETE) return
val subjectId = startedByUserId ?: return
val guesserId = if (subjectId == userId) partnerId else userId
val subjectAnswers = answers.byUser[subjectId].orEmpty()
val guesserAnswers = guesserId?.let { answers.byUser[it] }.orEmpty()
val results = _uiState.value.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))
}
_uiState.update {
it.copy(
currentIndex = if (next < total) next else it.currentIndex,
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN
phase = HowWellPhase.COMPLETE,
results = results,
score = results.count { r -> r.isMatch }
)
}
if (next >= total) {
viewModelScope.launch { finishSession() }
// Both have answered — release the one-game lock so a new game can start.
finishSession()
}
private fun HowWellRawAnswer?.toAnswer(): HowWellAnswer =
HowWellAnswer(this?.optionId, this?.scale)
/** 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 {
if (!submitted) finishSession()
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
}
}
fun restart() {
observeJob?.cancel()
sessionId = null
startedByUserId = null
submitted = false
myAnswers.clear()
_uiState.value = HowWellUiState()
load()
}
@ -278,6 +354,10 @@ class HowWellViewModel @Inject constructor(
_uiState.update { it.copy(navigateTo = null) }
}
private fun fail(message: String) {
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = message) }
}
companion object {
const val SESSION_SIZE = 10
private const val TAG = "HowWellViewModel"
@ -310,56 +390,43 @@ fun HowWellScreen(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
)
HowWellPhase.PLAYER_A_INTRO -> PlayerIntroScreen(
playerNumber = 1,
total = state.questions.size,
onReady = viewModel::startPlayerA
HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something went wrong.",
onBack = viewModel::quit
)
HowWellPhase.PLAYER_A_TURN -> {
HowWellPhase.INTRO -> PlayerIntroScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
total = state.questions.size,
onReady = viewModel::startAnswering
)
HowWellPhase.ANSWER -> {
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
AnswerScreen(
question = q,
index = state.currentIndex,
total = state.questions.size,
isPlayerB = false,
isGuesser = !state.amSubject,
partnerName = state.partnerName,
selectedOptionId = state.selectedOptionId,
selectedScale = state.selectedScale,
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmAnswer,
onConfirm = viewModel::confirm,
onQuit = viewModel::quit
)
}
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB)
HowWellPhase.PLAYER_B_TURN -> {
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
AnswerScreen(
question = q,
index = state.currentIndex,
total = state.questions.size,
isPlayerB = true,
selectedOptionId = state.selectedOptionId,
selectedScale = state.selectedScale,
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmPrediction,
onQuit = viewModel::quit
)
}
HowWellPhase.REVEALING -> {
val result = state.results.lastOrNull() ?: return@Box
RevealScreen(
result = result,
questionNumber = state.currentIndex + 1,
total = state.questions.size,
score = state.score,
onNext = viewModel::nextQuestion
)
}
HowWellPhase.WAITING -> HowWellWaitingScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
onBack = viewModel::quit
)
HowWellPhase.COMPLETE -> CompleteScreen(
score = state.score,
total = state.questions.size,
results = state.results,
amSubject = state.amSubject,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
)
@ -370,7 +437,12 @@ fun HowWellScreen(
// ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
private fun PlayerIntroScreen(
amSubject: Boolean,
partnerName: String,
total: Int,
onReady: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
@ -381,40 +453,31 @@ private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusGlyph(
icon = if (playerNumber == 1) Icons.Filled.Person else Icons.Filled.Psychology,
icon = if (amSubject) Icons.Filled.Person else Icons.Filled.Psychology,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
Spacer(Modifier.height(20.dp))
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Player $playerNumber",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(16.dp))
Text(
text = if (playerNumber == 1)
"Answer $total questions honestly.\nYour partner will try to predict what you said."
text = if (amSubject)
"Answer $total questions about yourself, honestly."
else
"For each question, guess what your partner answered.\nNo peeking!",
"Predict how $partnerName answered $total questions about themselves.",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.headlineSmall.lineHeight
)
if (playerNumber == 1) {
Spacer(Modifier.height(10.dp))
Text(
text = "Ask your partner to look away.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(10.dp))
Text(
text = if (amSubject)
"$partnerName guesses your answers on their own device. You'll both see how well they know you once you're both done."
else
"Answer on your own device — no peeking needed. You'll both see your score once you're both done.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
@ -426,42 +489,69 @@ private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit
}
@Composable
private fun HandoffScreen(onReady: () -> Unit) {
private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
StatusGlyph(
icon = Icons.Filled.Sync,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
Spacer(Modifier.height(20.dp))
Text(
text = "Pass the phone!",
text = "All done on your side!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
text = "Player 1 is done. Hand the phone to Player 2 — keep your answers secret!",
text = if (amSubject)
"Waiting for $partnerName to finish guessing — then you'll both see how well they know you."
else
"Waiting for $partnerName to finish answering — then you'll both see your score.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("I'm Player 2, let's go!", color = Color.White) }
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
}
}
@Composable
private fun HowWellErrorScreen(message: String, onBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
Text("Back to Play")
}
}
}
@ -470,7 +560,8 @@ private fun AnswerScreen(
question: Question,
index: Int,
total: Int,
isPlayerB: Boolean,
isGuesser: Boolean,
partnerName: String,
selectedOptionId: String?,
selectedScale: Int?,
onSelectOption: (String) -> Unit,
@ -495,7 +586,7 @@ private fun AnswerScreen(
) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Player ${if (isPlayerB) 2 else 1} · ${index + 1} / $total",
text = "${if (isGuesser) "Your guess" else "About you"} · ${index + 1} / $total",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
@ -523,10 +614,10 @@ private fun AnswerScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (isPlayerB) {
if (isGuesser) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "What did Player 1 say?",
text = "How did $partnerName answer?",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep,
@ -577,176 +668,20 @@ private fun AnswerScreen(
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
Text(
text = if (index + 1 >= total && !isPlayerB) "Done →" else "Confirm →",
text = if (index + 1 >= total) "Done →" else "Confirm →",
color = Color.White
)
}
}
}
@Composable
private fun RevealScreen(
result: HowWellResult,
questionNumber: Int,
total: Int,
score: Int,
onNext: () -> Unit
) {
val matchColor = Color(0xFF2E7D32)
val closeColor = Color(0xFFF57F17)
val missColor = Color(0xFFC62828)
val accentColor = when {
result.isMatch -> matchColor
result.isClose -> closeColor
else -> missColor
}
val bgColor = when {
result.isMatch -> Color(0xFFE8F5E9)
result.isClose -> Color(0xFFFFF8E1)
else -> Color(0xFFFCE4EC)
}
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$questionNumber / $total",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Score: $score / $questionNumber",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = bgColor),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
ResultGlyph(
isPositive = result.isMatch,
isClose = result.isClose,
size = 38.dp
)
Text(
text = if (result.isMatch) "Match!" else if (result.isClose) "So close!" else "Not quite",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = accentColor
)
}
}
HowWellScoreStrip(
score = score,
answered = questionNumber,
total = total
)
Text(
text = result.question.text,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AnswerRevealCard(
label = "Player 1 said",
text = result.playerAAnswer.displayText(result.question.answerConfig),
isMatch = result.isMatch,
modifier = Modifier.weight(1f)
)
AnswerRevealCard(
label = "Player 2 guessed",
text = result.playerBAnswer.displayText(result.question.answerConfig),
isMatch = result.isMatch,
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.weight(1f))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
Text(
text = if (questionNumber >= total) "See results" else "Next →",
color = Color.White
)
}
}
}
@Composable
private fun AnswerRevealCard(
label: String,
text: String,
isMatch: Boolean,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.heightIn(min = 90.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isMatch) Color(0xFF2E7D32) else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = if (isMatch) Color(0xFF1B5E20) else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun CompleteScreen(
score: Int,
total: Int,
results: List<HowWellResult>,
amSubject: Boolean,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
) {
@ -775,6 +710,15 @@ private fun CompleteScreen(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = if (amSubject)
"$partnerName guessed $score of $total about you"
else
"You guessed $score of $total about $partnerName",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
@ -830,45 +774,6 @@ private fun HowWellProgressPill(
}
}
@Composable
private fun HowWellScoreStrip(
score: Int,
answered: Int,
total: Int
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
color = CloserPalette.PurpleMist
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
HowWellProgressPill(
progress = score.toFloat() / answered.coerceAtLeast(1),
modifier = Modifier.weight(1f)
)
Text(
text = "$score / $answered read",
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "$total total",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun HowWellScoreRing(
score: Int,