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 CAPSULES = "capsules"
const val THIS_OR_THAT = "this_or_that" const val THIS_OR_THAT = "this_or_that"
const val WHEEL = "wheel" const val WHEEL = "wheel"
const val DESIRE_SYNC = "desire_sync"
const val HOW_WELL = "how_well"
} }
// ── Subcollections under couples/{coupleId}/daily_question/{date} ─────────── // ── 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.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -54,16 +53,17 @@ 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.FirestoreDesireSyncDataSource
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameHandle
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
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
@ -84,18 +84,16 @@ data class DesireMatch(
val label: String // human-friendly topic label val label: String // human-friendly topic label
) )
enum class DesireSyncPhase { enum class DesireSyncPhase { LOADING, INTRO, ANSWER, WAITING, REVEAL, ERROR }
LOADING, PARTNER_A_INTRO, PARTNER_A_TURN, HANDOFF,
PARTNER_B_INTRO, PARTNER_B_TURN, REVEAL
}
data class DesireSyncUiState( data class DesireSyncUiState(
val phase: DesireSyncPhase = DesireSyncPhase.LOADING, val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
val pairs: List<DesirePair> = emptyList(), val pairs: List<DesirePair> = emptyList(),
val currentIndex: Int = 0, val currentIndex: Int = 0,
val partnerAAnswers: List<String> = emptyList(),
val partnerBAnswers: List<String> = emptyList(),
val pendingSelection: String? = null, val pendingSelection: String? = null,
val myAnswers: List<String> = emptyList(),
val amStarter: Boolean = true,
val partnerName: String = "Your partner",
val matches: List<DesireMatch> = emptyList(), val matches: List<DesireMatch> = emptyList(),
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
@ -120,174 +118,230 @@ private fun topicLabel(femaleQ: Question): String =
@HiltViewModel @HiltViewModel
class DesireSyncViewModel @Inject constructor( class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState()) private val _uiState = MutableStateFlow(DesireSyncUiState())
val uiState: StateFlow<DesireSyncUiState> = _uiState.asStateFlow() val uiState: StateFlow<DesireSyncUiState> = _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 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() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val female = runCatching { repository.getDesireSyncQuestions("female") } val uid = gameSessionManager.currentUserId
.onFailure { Log.w(TAG, "load female failed", it) } ?: return@launch fail("You need to be signed in to play.")
.getOrElse { emptyList() } val couple = gameSessionManager.getCoupleForUser(uid)
val male = runCatching { repository.getDesireSyncQuestions("male") } ?: return@launch fail("Pair with your partner to play together.")
.onFailure { Log.w(TAG, "load male failed", it) }
.getOrElse { emptyList() }
val filteredFemale = female.filter { it.sex == "female" } userId = uid
val filteredMale = male.filter { it.sex == "male" } coupleId = couple.id
val maleById = filteredMale.associateBy { it.id.replace("_male_", "_") } partnerId = couple.userIds.firstOrNull { it != uid }
val pairs = filteredFemale partnerId?.let { pid ->
.filter { isBinaryQuestion(it) } runCatching { gameSessionManager.getUser(pid)?.displayName }
.shuffled() .getOrNull()
.take(SESSION_SIZE) ?.takeIf { it.isNotBlank() }
.mapNotNull { fq -> ?.let { name -> _uiState.update { s -> s.copy(partnerName = name) } }
val key = fq.id.replace("_female_", "_") }
maleById[key]?.let { mq -> DesirePair(fq, mq) }
}
_uiState.update { val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
it.copy( when {
phase = if (pairs.isEmpty()) DesireSyncPhase.LOADING else DesireSyncPhase.PARTNER_A_INTRO, active != null && active.gameType == GameType.DESIRE_SYNC ->
pairs = pairs, joinSession(uid, active.id, active.startedByUserId, active.questionIds)
error = if (pairs.isEmpty()) "No questions available." else null 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() { /** First partner: pick the topic set, open the shared session, answer their own side. */
startSession() private suspend fun createSession(uid: String) {
_uiState.update { val pairs = buildPairs(loadFemale(), loadMale()).shuffled().take(SESSION_SIZE)
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0) 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) { fun select(optionId: String) {
val s = _uiState.value val s = _uiState.value
if (s.pendingSelection != null) return if (s.pendingSelection != null || s.phase != DesireSyncPhase.ANSWER) return
_uiState.update { it.copy(pendingSelection = optionId) } _uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch { viewModelScope.launch {
delay(ADVANCE_DELAY_MS) delay(ADVANCE_DELAY_MS)
_uiState.update { state -> val answers = _uiState.value.myAnswers + optionId
val newAnswers = state.partnerAAnswers + optionId val next = _uiState.value.currentIndex + 1
val next = state.currentIndex + 1 if (next >= _uiState.value.pairs.size) {
if (next >= state.pairs.size) { _uiState.update {
state.copy( it.copy(pendingSelection = null, myAnswers = answers, phase = DesireSyncPhase.WAITING)
partnerAAnswers = newAnswers, }
pendingSelection = null, submitAnswers(answers)
phase = DesireSyncPhase.HANDOFF } else {
) _uiState.update {
} else { it.copy(pendingSelection = null, currentIndex = next, myAnswers = answers)
state.copy(
partnerAAnswers = newAnswers,
pendingSelection = null,
currentIndex = next
)
} }
} }
} }
} }
fun selectB(optionId: String) { private suspend fun submitAnswers(answers: List<String>) {
val s = _uiState.value submitted = true
if (s.pendingSelection != null) return val cId = coupleId ?: return
_uiState.update { it.copy(pendingSelection = optionId) } val sId = sessionId ?: return
viewModelScope.launch { val uid = userId ?: return
delay(ADVANCE_DELAY_MS) runCatching { dataSource.submitAnswers(cId, sId, uid, answers) }
_uiState.update { state -> .onFailure { Log.w(TAG, "Could not submit answers", it) }
val newAnswers = state.partnerBAnswers + optionId // The observer flips WAITING → REVEAL once the partner's answers land.
val next = state.currentIndex + 1 }
if (next >= state.pairs.size) {
val matches = computeMatches(state.pairs, state.partnerAAnswers, newAnswers) /** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */
state.copy( private fun observeReveal() {
partnerBAnswers = newAnswers, val cId = coupleId ?: return
pendingSelection = null, val sId = sessionId ?: return
matches = matches, observeJob?.cancel()
phase = DesireSyncPhase.REVEAL observeJob = viewModelScope.launch {
) dataSource.observeAnswers(cId, sId).collect { answers ->
} else { val mine = userId?.let { answers.byUser[it] }
state.copy( val theirs = partnerId?.let { answers.byUser[it] }
partnerBAnswers = newAnswers, when {
pendingSelection = null, mine != null && theirs != null -> revealResult(mine, theirs)
currentIndex = next 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 { private fun revealResult(mine: List<String>, theirs: List<String>) {
it.copy(phase = DesireSyncPhase.PARTNER_B_TURN, currentIndex = 0, pendingSelection = null) 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() { fun restart() {
observeJob?.cancel()
sessionId = null
submitted = false
_uiState.value = DesireSyncUiState() _uiState.value = DesireSyncUiState()
load() 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() { fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }
private fun fail(message: String) {
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = message) }
}
companion object { companion object {
private const val SESSION_SIZE = 10 private const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 380L private const val ADVANCE_DELAY_MS = 380L
@ -321,15 +375,18 @@ fun DesireSyncScreen(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
color = CloserPalette.Romantic color = CloserPalette.Romantic
) )
DesireSyncPhase.PARTNER_A_INTRO -> DSIntroScreen( DesireSyncPhase.ERROR -> DSErrorScreen(
playerNumber = 1, message = state.error ?: "Something went wrong.",
total = state.pairs.size, onBack = viewModel::quit
onReady = viewModel::startPartnerA
) )
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 val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
DSAnswerScreen( DSAnswerScreen(
question = pair.femaleQ, question = if (state.amStarter) pair.femaleQ else pair.maleQ,
index = state.currentIndex, index = state.currentIndex,
total = state.pairs.size, total = state.pairs.size,
pendingSelection = state.pendingSelection, pendingSelection = state.pendingSelection,
@ -337,26 +394,14 @@ fun DesireSyncScreen(
onQuit = viewModel::quit onQuit = viewModel::quit
) )
} }
DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB) DesireSyncPhase.WAITING -> DSWaitingScreen(
DesireSyncPhase.PARTNER_B_INTRO -> DSIntroScreen( partnerName = state.partnerName,
playerNumber = 2, onBack = viewModel::quit
total = state.pairs.size,
onReady = viewModel::startPartnerB
) )
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( DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches, matches = state.matches,
total = state.pairs.size, total = state.pairs.size,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = viewModel::quit onHome = viewModel::quit
) )
@ -367,7 +412,7 @@ fun DesireSyncScreen(
// ── Phase screens ───────────────────────────────────────────────────────────── // ── Phase screens ─────────────────────────────────────────────────────────────
@Composable @Composable
private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) { private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -378,33 +423,20 @@ private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
StatusGlyph( StatusGlyph(
icon = if (playerNumber == 1) Icons.Filled.FavoriteBorder else Icons.Filled.Visibility, icon = Icons.Filled.FavoriteBorder,
tint = CloserPalette.Romantic, tint = CloserPalette.Romantic,
container = CloserPalette.Romantic.copy(alpha = 0.12f) container = CloserPalette.Romantic.copy(alpha = 0.12f)
) )
Spacer(Modifier.height(20.dp)) 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(
text = if (playerNumber == 1) text = "Answer $total questions privately — just tap Yes or No.",
"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.",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@ -420,42 +452,66 @@ private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
} }
@Composable @Composable
private fun DSHandoffScreen(onReady: () -> Unit) { private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp), .padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(Modifier.weight(1f))
StatusGlyph( StatusGlyph(
icon = Icons.Filled.Sync, icon = Icons.Filled.Favorite,
tint = CloserPalette.Romantic, tint = CloserPalette.Romantic,
container = CloserPalette.Romantic.copy(alpha = 0.12f) container = CloserPalette.Romantic.copy(alpha = 0.12f)
) )
Spacer(Modifier.height(20.dp))
Text( Text(
text = "Pass the phone!", text = "Your answers 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
) )
Spacer(Modifier.height(10.dp))
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(36.dp)) Spacer(Modifier.height(4.dp))
Button( CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp)
onClick = onReady, Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp)
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic) ) { Text("Back to Play") }
) { Text("I'm Partner B, let's go!", color = Color.White) } }
}
@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( private fun DSRevealScreen(
matches: List<DesireMatch>, matches: List<DesireMatch>,
total: Int, total: Int,
partnerName: String,
onPlayAgain: () -> Unit, onPlayAgain: () -> Unit,
onHome: () -> Unit onHome: () -> Unit
) { ) {
@ -605,7 +662,7 @@ private fun DSRevealScreen(
textAlign = TextAlign.Center 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 @Composable
private fun DesireRevealMeter( private fun DesireRevealMeter(
matches: Int, matches: Int,
total: Int total: Int,
partnerName: String
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -703,7 +761,7 @@ private fun DesireRevealMeter(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
DesirePrivacyTile( DesirePrivacyTile(
label = "Partner A", label = "You",
value = "$total private", value = "$total private",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@ -715,7 +773,7 @@ private fun DesireRevealMeter(
iconSize = 20.dp iconSize = 20.dp
) )
DesirePrivacyTile( DesirePrivacyTile(
label = "Partner B", label = partnerName,
value = "$total private", value = "$total private",
modifier = Modifier.weight(1f) 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.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.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.domain.usecase.GameSessionManager
import app.closer.ui.components.ResultGlyph import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
@ -72,6 +74,7 @@ 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 kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -83,9 +86,7 @@ import kotlinx.coroutines.launch
data class HowWellAnswer( data class HowWellAnswer(
val selectedOptionId: String? = null, val selectedOptionId: String? = null,
val scaleValue: Int? = null val scaleValue: Int? = null
) { )
val isEmpty get() = selectedOptionId == null && scaleValue == null
}
fun HowWellAnswer.isMatch(other: HowWellAnswer): Boolean = when { fun HowWellAnswer.isMatch(other: HowWellAnswer): Boolean = when {
selectedOptionId != null -> selectedOptionId == other.selectedOptionId selectedOptionId != null -> selectedOptionId == other.selectedOptionId
@ -119,19 +120,18 @@ data class HowWellResult(
val isClose: Boolean val isClose: Boolean
) )
enum class HowWellPhase { enum class HowWellPhase { LOADING, INTRO, ANSWER, WAITING, COMPLETE, ERROR }
LOADING, PLAYER_A_INTRO, PLAYER_A_TURN, HANDOFF,
PLAYER_B_TURN, REVEALING, COMPLETE
}
data class HowWellUiState( data class HowWellUiState(
val phase: HowWellPhase = HowWellPhase.LOADING, val phase: HowWellPhase = HowWellPhase.LOADING,
val questions: List<Question> = emptyList(), val questions: List<Question> = emptyList(),
val currentIndex: Int = 0, val currentIndex: Int = 0,
val playerAAnswers: List<HowWellAnswer> = emptyList(),
val results: List<HowWellResult> = emptyList(),
val selectedOptionId: String? = null, val selectedOptionId: String? = null,
val selectedScale: Int? = 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 score: Int = 0,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
@ -142,134 +142,210 @@ data class HowWellUiState(
@HiltViewModel @HiltViewModel
class HowWellViewModel @Inject constructor( class HowWellViewModel @Inject constructor(
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HowWellUiState()) private val _uiState = MutableStateFlow(HowWellUiState())
val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow() val uiState: StateFlow<HowWellUiState> = _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 startedByUserId: String? = null
private val myAnswers = mutableListOf<HowWellRawAnswer>()
private var observeJob: Job? = null
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 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() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val questions = runCatching { val uid = gameSessionManager.currentUserId
repository.getQuestionsForPrediction().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 { it.copy(partnerName = name) } }
} }
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() } val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
_uiState.update { when {
it.copy( active != null && active.gameType == GameType.HOW_WELL ->
phase = if (questions.isEmpty()) HowWellPhase.LOADING else HowWellPhase.PLAYER_A_INTRO, joinSession(uid, active.id, active.startedByUserId, active.questionIds)
questions = questions, active != null ->
error = if (questions.isEmpty()) "No questions available." else 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 selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) }
fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) } fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) }
fun startPlayerA() { fun startAnswering() {
startSession() _uiState.update {
_uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) } it.copy(phase = HowWellPhase.ANSWER, currentIndex = 0, selectedOptionId = null, selectedScale = null)
}
} }
fun confirmAnswer() { fun confirm() {
val s = _uiState.value val s = _uiState.value
val answer = HowWellAnswer(s.selectedOptionId, s.selectedScale) if (s.phase != HowWellPhase.ANSWER) return
if (answer.isEmpty) return if (s.selectedOptionId == null && s.selectedScale == null) return
val newAnswers = s.playerAAnswers + answer myAnswers.add(HowWellRawAnswer(s.selectedOptionId, s.selectedScale))
val next = s.currentIndex + 1 val next = s.currentIndex + 1
_uiState.update { if (next >= s.questions.size) {
it.copy( _uiState.update {
playerAAnswers = newAnswers, it.copy(selectedOptionId = null, selectedScale = null, phase = HowWellPhase.WAITING)
selectedOptionId = null, }
selectedScale = null, viewModelScope.launch { submitAnswers() }
currentIndex = if (next < it.questions.size) next else it.currentIndex, } else {
phase = if (next >= it.questions.size) HowWellPhase.HANDOFF else HowWellPhase.PLAYER_A_TURN _uiState.update { it.copy(currentIndex = next, selectedOptionId = null, selectedScale = null) }
)
} }
} }
fun readyForPlayerB() = _uiState.update { private suspend fun submitAnswers() {
it.copy(phase = HowWellPhase.PLAYER_B_TURN, currentIndex = 0, selectedOptionId = null, selectedScale = null) 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() { /** Single source of truth for WAITING/COMPLETE: driven by what's in Firestore. */
val s = _uiState.value private fun observeReveal() {
val prediction = HowWellAnswer(s.selectedOptionId, s.selectedScale) val cId = coupleId ?: return
if (prediction.isEmpty) return val sId = sessionId ?: return
val actual = s.playerAAnswers.getOrNull(s.currentIndex) ?: return observeJob?.cancel()
val question = s.questions.getOrNull(s.currentIndex) ?: return observeJob = viewModelScope.launch {
val match = prediction.isMatch(actual) dataSource.observeAnswers(cId, sId).collect { answers ->
val close = prediction.isClose(actual) val mine = userId?.let { answers.byUser[it] }
_uiState.update { val theirs = partnerId?.let { answers.byUser[it] }
it.copy( when {
results = it.results + HowWellResult(question, actual, prediction, match, close), !mine.isNullOrEmpty() && !theirs.isNullOrEmpty() -> revealResult(answers)
selectedOptionId = null, !mine.isNullOrEmpty() -> _uiState.update {
selectedScale = null, if (it.phase == HowWellPhase.COMPLETE) it else it.copy(phase = HowWellPhase.WAITING)
score = if (match) it.score + 1 else it.score, }
phase = HowWellPhase.REVEALING // else: I haven't answered yet — stay on INTRO/ANSWER.
) }
}
} }
} }
fun nextQuestion() { private fun revealResult(answers: HowWellAnswers) {
val next = _uiState.value.currentIndex + 1 if (_uiState.value.phase == HowWellPhase.COMPLETE) return
val total = _uiState.value.questions.size 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 { _uiState.update {
it.copy( it.copy(
currentIndex = if (next < total) next else it.currentIndex, phase = HowWellPhase.COMPLETE,
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN results = results,
score = results.count { r -> r.isMatch }
) )
} }
if (next >= total) { // Both have answered — release the one-game lock so a new game can start.
viewModelScope.launch { finishSession() } 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() { fun restart() {
observeJob?.cancel()
sessionId = null
startedByUserId = null
submitted = false
myAnswers.clear()
_uiState.value = HowWellUiState() _uiState.value = HowWellUiState()
load() load()
} }
@ -278,6 +354,10 @@ class HowWellViewModel @Inject constructor(
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }
private fun fail(message: String) {
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = message) }
}
companion object { companion object {
const val SESSION_SIZE = 10 const val SESSION_SIZE = 10
private const val TAG = "HowWellViewModel" private const val TAG = "HowWellViewModel"
@ -310,56 +390,43 @@ fun HowWellScreen(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep color = CloserPalette.PurpleDeep
) )
HowWellPhase.PLAYER_A_INTRO -> PlayerIntroScreen( HowWellPhase.ERROR -> HowWellErrorScreen(
playerNumber = 1, message = state.error ?: "Something went wrong.",
total = state.questions.size, onBack = viewModel::quit
onReady = viewModel::startPlayerA
) )
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 val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
AnswerScreen( AnswerScreen(
question = q, question = q,
index = state.currentIndex, index = state.currentIndex,
total = state.questions.size, total = state.questions.size,
isPlayerB = false, isGuesser = !state.amSubject,
partnerName = state.partnerName,
selectedOptionId = state.selectedOptionId, selectedOptionId = state.selectedOptionId,
selectedScale = state.selectedScale, selectedScale = state.selectedScale,
onSelectOption = viewModel::selectOption, onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale, onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmAnswer, onConfirm = viewModel::confirm,
onQuit = viewModel::quit onQuit = viewModel::quit
) )
} }
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB) HowWellPhase.WAITING -> HowWellWaitingScreen(
HowWellPhase.PLAYER_B_TURN -> { amSubject = state.amSubject,
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box partnerName = state.partnerName,
AnswerScreen( onBack = viewModel::quit
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.COMPLETE -> CompleteScreen( HowWellPhase.COMPLETE -> CompleteScreen(
score = state.score, score = state.score,
total = state.questions.size, total = state.questions.size,
results = state.results, results = state.results,
amSubject = state.amSubject,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart, onPlayAgain = viewModel::restart,
onHome = viewModel::quit onHome = viewModel::quit
) )
@ -370,7 +437,12 @@ fun HowWellScreen(
// ── Phase screens ───────────────────────────────────────────────────────────── // ── Phase screens ─────────────────────────────────────────────────────────────
@Composable @Composable
private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) { private fun PlayerIntroScreen(
amSubject: Boolean,
partnerName: String,
total: Int,
onReady: () -> Unit
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -381,40 +453,31 @@ private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
StatusGlyph( 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, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
) )
Spacer(Modifier.height(20.dp)) 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(
text = if (playerNumber == 1) text = if (amSubject)
"Answer $total questions honestly.\nYour partner will try to predict what you said." "Answer $total questions about yourself, honestly."
else 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), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.headlineSmall.lineHeight lineHeight = MaterialTheme.typography.headlineSmall.lineHeight
) )
if (playerNumber == 1) { Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(10.dp)) Text(
Text( text = if (amSubject)
text = "Ask your partner to look away.", "$partnerName guesses your answers on their own device. You'll both see how well they know you once you're both done."
style = MaterialTheme.typography.bodyMedium, else
color = MaterialTheme.colorScheme.onSurfaceVariant, "Answer on your own device — no peeking needed. You'll both see your score once you're both done.",
textAlign = TextAlign.Center style = MaterialTheme.typography.bodyMedium,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
} textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp)) Spacer(Modifier.height(36.dp))
Button( Button(
onClick = onReady, onClick = onReady,
@ -426,42 +489,69 @@ private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit
} }
@Composable @Composable
private fun HandoffScreen(onReady: () -> Unit) { private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp), .padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(Modifier.weight(1f))
StatusGlyph( StatusGlyph(
icon = Icons.Filled.Sync, icon = Icons.Filled.Sync,
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist container = CloserPalette.PurpleMist
) )
Spacer(Modifier.height(20.dp))
Text( Text(
text = "Pass the phone!", text = "All done on your side!",
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
) )
Spacer(Modifier.height(10.dp))
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(36.dp)) Spacer(Modifier.height(4.dp))
Button( CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
onClick = onReady, Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp)
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep) ) { Text("Back to Play") }
) { Text("I'm Player 2, let's go!", color = Color.White) } }
}
@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, question: Question,
index: Int, index: Int,
total: Int, total: Int,
isPlayerB: Boolean, isGuesser: Boolean,
partnerName: String,
selectedOptionId: String?, selectedOptionId: String?,
selectedScale: Int?, selectedScale: Int?,
onSelectOption: (String) -> Unit, onSelectOption: (String) -> Unit,
@ -495,7 +586,7 @@ private fun AnswerScreen(
) { ) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) { Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text( 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), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep, color = CloserPalette.PurpleDeep,
@ -523,10 +614,10 @@ private fun AnswerScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (isPlayerB) { if (isGuesser) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) { Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text( Text(
text = "What did Player 1 say?", text = "How did $partnerName answer?",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep, color = CloserPalette.PurpleDeep,
@ -577,176 +668,20 @@ private fun AnswerScreen(
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep) colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { ) {
Text( Text(
text = if (index + 1 >= total && !isPlayerB) "Done →" else "Confirm →", text = if (index + 1 >= total) "Done →" else "Confirm →",
color = Color.White 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 @Composable
private fun CompleteScreen( private fun CompleteScreen(
score: Int, score: Int,
total: Int, total: Int,
results: List<HowWellResult>, results: List<HowWellResult>,
amSubject: Boolean,
partnerName: String,
onPlayAgain: () -> Unit, onPlayAgain: () -> Unit,
onHome: () -> Unit onHome: () -> Unit
) { ) {
@ -775,6 +710,15 @@ private fun CompleteScreen(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center 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 @Composable
private fun HowWellScoreRing( private fun HowWellScoreRing(
score: Int, score: Int,