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 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} ───────────
|
||||||
|
|
|
||||||
|
|
@ -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.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)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue