feat: add DesireSync + HowWell Firestore data sources, update screens with cloud-backed answering
This commit is contained in:
parent
97cc334136
commit
473feb78a9
|
|
@ -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} ───────────
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue