diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index ce92ac7e..2c1d18c6 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -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} ─────────── diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt new file mode 100644 index 00000000..3243a0ee --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt @@ -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> = 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 + ) { + 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 = + 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> { + @Suppress("UNCHECKED_CAST") + val map = raw as? Map ?: return emptyMap() + return map.mapNotNull { (uid, value) -> + (value as? List<*>)?.filterIsInstance()?.let { uid to it } + }.toMap() + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt new file mode 100644 index 00000000..3a64a6ee --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt @@ -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> = 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 + ) { + 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 = + 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> { + @Suppress("UNCHECKED_CAST") + val map = raw as? Map ?: 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() + } +} diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index a5287cc6..357ea60f 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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 = emptyList(), val currentIndex: Int = 0, - val partnerAAnswers: List = emptyList(), - val partnerBAnswers: List = emptyList(), val pendingSelection: String? = null, + val myAnswers: List = emptyList(), + val amStarter: Boolean = true, + val partnerName: String = "Your partner", val matches: List = 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 = _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 + ) { + 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 = + runCatching { repository.getDesireSyncQuestions("female") } + .onFailure { Log.w(TAG, "load female failed", it) } + .getOrElse { emptyList() } + .filter { it.sex == "female" } + + private suspend fun loadMale(): List = + runCatching { repository.getDesireSyncQuestions("male") } + .onFailure { Log.w(TAG, "load male failed", it) } + .getOrElse { emptyList() } + .filter { it.sex == "male" } + + private fun buildPairs(female: List, male: List): List { + 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) { + 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, theirs: List) { + 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, - aAnswers: List, - bAnswers: List - ): List = 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, 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) ) diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index dc07f93b..2ce8e2ba 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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 = emptyList(), val currentIndex: Int = 0, - val playerAAnswers: List = emptyList(), - val results: List = 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 = 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 = _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() + 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 + ) { + 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, + 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,