feat: add ThisOrThat Firestore data source, updated screen with question answering flow
This commit is contained in:
parent
af06cb2123
commit
408a2f24ba
|
|
@ -32,6 +32,7 @@ object FirestoreCollections {
|
||||||
const val DAILY_QUESTION = "daily_question"
|
const val DAILY_QUESTION = "daily_question"
|
||||||
const val CHALLENGES = "challenges"
|
const val CHALLENGES = "challenges"
|
||||||
const val CAPSULES = "capsules"
|
const val CAPSULES = "capsules"
|
||||||
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
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' picks for one This-or-That session, keyed by userId. Each list
|
||||||
|
* holds the chosen optionIds aligned to the session's `questionIds` order, so
|
||||||
|
* `byUser[a][i]` and `byUser[b][i]` are answers to the same prompt.
|
||||||
|
*/
|
||||||
|
data class ThisOrThatAnswers(
|
||||||
|
val byUser: Map<String, List<String>> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores per-user answers for the async This-or-That reveal at
|
||||||
|
* `couples/{coupleId}/this_or_that/{sessionId}`. The session itself (the shared
|
||||||
|
* question set + the one-active-game lock) is owned by [app.closer.domain.usecase.GameSessionManager];
|
||||||
|
* this only carries the answers so each partner can play on their own device and
|
||||||
|
* the result reveals once both have submitted.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore
|
||||||
|
) {
|
||||||
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.THIS_OR_THAT)
|
||||||
|
.document(sessionId)
|
||||||
|
|
||||||
|
/** Persist this user's full set of picks (optionIds in `questionIds` 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live view of both partners' picks; emits whenever either side submits. */
|
||||||
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
||||||
|
callbackFlow {
|
||||||
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val raw = snap.get("answers") as? Map<String, *>
|
||||||
|
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
|
||||||
|
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
|
||||||
|
}.toMap()
|
||||||
|
trySend(ThisOrThatAnswers(byUser))
|
||||||
|
}
|
||||||
|
awaitClose { reg.remove() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,10 +23,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -39,7 +42,6 @@ import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.VerticalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -60,17 +62,18 @@ 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.FirestoreThisOrThatDataSource
|
||||||
import app.closer.domain.model.ChoiceOption
|
import app.closer.domain.model.ChoiceOption
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.model.ThisOrThatAnswerConfig
|
import app.closer.domain.model.ThisOrThatAnswerConfig
|
||||||
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.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
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
|
||||||
|
|
@ -80,14 +83,25 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── ViewModel ────────────────────────────────────────────────────────────────
|
// ── ViewModel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum class TotPhase { LOADING, PLAYING, WAITING, REVEAL, ERROR }
|
||||||
|
|
||||||
|
/** One prompt's outcome: what each partner picked and whether they matched. */
|
||||||
|
data class RevealCard(
|
||||||
|
val questionText: String,
|
||||||
|
val myText: String,
|
||||||
|
val partnerText: String,
|
||||||
|
val agreed: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
data class ThisOrThatUiState(
|
data class ThisOrThatUiState(
|
||||||
val isLoading: Boolean = true,
|
val phase: TotPhase = TotPhase.LOADING,
|
||||||
val questions: List<Question> = emptyList(),
|
val questions: List<Question> = emptyList(),
|
||||||
val currentIndex: Int = 0,
|
val currentIndex: Int = 0,
|
||||||
val pendingSelection: String? = null,
|
val pendingSelection: String? = null,
|
||||||
val aCount: Int = 0,
|
val myAnswers: List<String> = emptyList(),
|
||||||
val bCount: Int = 0,
|
val partnerName: String = "Your partner",
|
||||||
val isComplete: Boolean = false,
|
val matchedCount: Int = 0,
|
||||||
|
val revealCards: List<RevealCard> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
@ -95,101 +109,208 @@ data class ThisOrThatUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ThisOrThatViewModel @Inject constructor(
|
class ThisOrThatViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val gameSessionManager: GameSessionManager
|
private val gameSessionManager: GameSessionManager,
|
||||||
|
private val dataSource: FirestoreThisOrThatDataSource
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ThisOrThatUiState())
|
private val _uiState = MutableStateFlow(ThisOrThatUiState())
|
||||||
val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ThisOrThatUiState> = _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 load() {
|
private fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val questions = runCatching {
|
val uid = gameSessionManager.currentUserId
|
||||||
repository.getQuestionsByType("this_or_that").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 { s -> s.copy(partnerName = name) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
|
||||||
|
when {
|
||||||
|
active != null && active.gameType == GameType.THIS_OR_THAT ->
|
||||||
|
joinSession(active.id, 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: pick a fixed set of prompts and open the shared session. */
|
||||||
|
private suspend fun createSession(uid: String) {
|
||||||
|
val picked = runCatching { repository.getQuestionsByType("this_or_that") }
|
||||||
.onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) }
|
.onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) }
|
||||||
.getOrElse { emptyList() }
|
.getOrElse { emptyList() }
|
||||||
|
.shuffled()
|
||||||
|
.take(SESSION_SIZE)
|
||||||
|
if (picked.isEmpty()) return fail("No questions available.")
|
||||||
|
|
||||||
_uiState.update {
|
val startResult = runCatching {
|
||||||
it.copy(
|
gameSessionManager.startGame(
|
||||||
isLoading = false,
|
userId = uid,
|
||||||
questions = questions,
|
gameType = GameType.THIS_OR_THAT,
|
||||||
error = if (questions.isEmpty()) "No questions available." else null
|
questionIds = picked.map { it.id }
|
||||||
)
|
)
|
||||||
|
}.getOrElse { Result.failure(it) }
|
||||||
|
|
||||||
|
when {
|
||||||
|
startResult.isSuccess -> {
|
||||||
|
sessionId = startResult.getOrThrow()
|
||||||
|
_uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) }
|
||||||
|
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.")
|
||||||
}
|
}
|
||||||
// No intro screen — play begins immediately, so open the session now.
|
|
||||||
if (questions.isNotEmpty()) startSession()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startSession() {
|
/** Second partner: join the in-flight session with the exact same prompts, in the same order. */
|
||||||
viewModelScope.launch {
|
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
|
||||||
gameSessionManager.startGameForCurrentUser(gameType = GameType.THIS_OR_THAT)
|
sessionId = existingSessionId
|
||||||
.onSuccess { gameHandle = it }
|
val byId = runCatching { repository.getQuestionsByType("this_or_that") }
|
||||||
.onFailure { Log.w(TAG, "Could not start session", it) }
|
.getOrElse { emptyList() }
|
||||||
}
|
.associateBy { it.id }
|
||||||
|
val questions = questionIds.mapNotNull { byId[it] }
|
||||||
|
if (questions.isEmpty()) return fail("Could not load this game.")
|
||||||
|
_uiState.update { it.copy(phase = TotPhase.PLAYING, questions = questions) }
|
||||||
|
observeReveal()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marks the active session completed (idempotent — no-op if already finished). */
|
/** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */
|
||||||
private suspend fun finishSession() {
|
private fun observeReveal() {
|
||||||
val handle = gameHandle ?: return
|
val cId = coupleId ?: return
|
||||||
gameHandle = null
|
val sId = sessionId ?: return
|
||||||
gameSessionManager.finishGameForCurrentUser(handle)
|
observeJob?.cancel()
|
||||||
.onFailure { Log.w(TAG, "Could not finish session", it) }
|
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 == TotPhase.REVEAL) it else it.copy(phase = TotPhase.WAITING)
|
||||||
|
}
|
||||||
|
// else: I still haven't answered — stay on PLAYING.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finish any dangling session, then route back to the Play hub. */
|
|
||||||
fun quit() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
finishSession()
|
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun select(optionId: String) {
|
fun select(optionId: String) {
|
||||||
val s = _uiState.value
|
val s = _uiState.value
|
||||||
if (s.pendingSelection != null || s.isComplete || s.isLoading) return
|
if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return
|
||||||
val config = s.questions.getOrNull(s.currentIndex)
|
_uiState.update { it.copy(pendingSelection = optionId) }
|
||||||
?.answerConfig as? ThisOrThatAnswerConfigImpl ?: return
|
viewModelScope.launch {
|
||||||
val isA = config.config.optionA.id == optionId
|
delay(ADVANCE_DELAY_MS)
|
||||||
|
val answers = _uiState.value.myAnswers + optionId
|
||||||
|
val next = _uiState.value.currentIndex + 1
|
||||||
|
if (next >= _uiState.value.questions.size) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(pendingSelection = null, myAnswers = answers, phase = TotPhase.WAITING)
|
||||||
pendingSelection = optionId,
|
}
|
||||||
aCount = if (isA) it.aCount + 1 else it.aCount,
|
submitAnswers(answers)
|
||||||
bCount = if (!isA) it.bCount + 1 else it.bCount
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(pendingSelection = null, currentIndex = next, myAnswers = answers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun submitAnswers(answers: List<String>) {
|
||||||
|
submitted = true
|
||||||
|
val cId = coupleId ?: return
|
||||||
|
val sId = sessionId ?: return
|
||||||
|
val uid = userId ?: return
|
||||||
|
runCatching { dataSource.submitAnswers(cId, sId, uid, answers) }
|
||||||
|
.onFailure { Log.w(TAG, "Could not submit answers", it) }
|
||||||
|
// The observer flips WAITING → REVEAL once the partner's answers land
|
||||||
|
// (or right away, if they finished first).
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun revealResult(mine: List<String>, theirs: List<String>) {
|
||||||
|
if (_uiState.value.phase == TotPhase.REVEAL) return
|
||||||
|
val questions = _uiState.value.questions
|
||||||
|
val cards = questions.mapIndexed { i, q ->
|
||||||
|
val config = q.answerConfig as? ThisOrThatAnswerConfigImpl
|
||||||
|
val myOpt = mine.getOrNull(i)
|
||||||
|
val theirOpt = theirs.getOrNull(i)
|
||||||
|
RevealCard(
|
||||||
|
questionText = q.text,
|
||||||
|
myText = optionText(config, myOpt),
|
||||||
|
partnerText = optionText(config, theirOpt),
|
||||||
|
agreed = myOpt != null && myOpt == theirOpt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
delay(420)
|
|
||||||
val next = s.currentIndex + 1
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
if (next >= it.questions.size)
|
it.copy(
|
||||||
it.copy(pendingSelection = null, isComplete = true)
|
phase = TotPhase.REVEAL,
|
||||||
else
|
revealCards = cards,
|
||||||
it.copy(pendingSelection = null, currentIndex = next)
|
matchedCount = cards.count { c -> c.agreed }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (_uiState.value.isComplete) finishSession()
|
// Both have answered — release the one-game lock so a new game can start.
|
||||||
|
finishSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionText(config: ThisOrThatAnswerConfigImpl?, optionId: String?): String =
|
||||||
|
when (optionId) {
|
||||||
|
null -> "—"
|
||||||
|
config?.config?.optionA?.id -> config.config.optionA.text
|
||||||
|
config?.config?.optionB?.id -> config.config.optionB.text
|
||||||
|
else -> optionId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = ThisOrThatUiState()
|
_uiState.value = ThisOrThatUiState()
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
@ -198,8 +319,13 @@ class ThisOrThatViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(navigateTo = null) }
|
_uiState.update { it.copy(navigateTo = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fail(message: String) {
|
||||||
|
_uiState.update { it.copy(phase = TotPhase.ERROR, error = message) }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SESSION_SIZE = 10
|
const val SESSION_SIZE = 10
|
||||||
|
private const val ADVANCE_DELAY_MS = 420L
|
||||||
private const val TAG = "ThisOrThatViewModel"
|
private const val TAG = "ThisOrThatViewModel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,24 +351,35 @@ fun ThisOrThatScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(closerBackgroundBrush())
|
.background(closerBackgroundBrush())
|
||||||
) {
|
) {
|
||||||
when {
|
when (state.phase) {
|
||||||
state.isLoading -> CircularProgressIndicator(
|
TotPhase.LOADING -> CircularProgressIndicator(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
color = CloserPalette.PurpleDeep
|
color = CloserPalette.PurpleDeep
|
||||||
)
|
)
|
||||||
state.error != null -> ErrorState(
|
TotPhase.ERROR -> ErrorState(
|
||||||
message = state.error!!,
|
message = state.error ?: "Something went wrong.",
|
||||||
onBack = viewModel::quit
|
onBack = viewModel::quit
|
||||||
)
|
)
|
||||||
state.isComplete -> ThisOrThatComplete(
|
TotPhase.WAITING -> WaitingForRevealScreen(
|
||||||
aCount = state.aCount,
|
partnerName = state.partnerName,
|
||||||
bCount = state.bCount,
|
onBack = viewModel::quit
|
||||||
total = state.questions.size,
|
)
|
||||||
|
TotPhase.REVEAL -> ThisOrThatReveal(
|
||||||
|
matched = state.matchedCount,
|
||||||
|
total = state.revealCards.size,
|
||||||
|
partnerName = state.partnerName,
|
||||||
|
cards = state.revealCards,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onHome = viewModel::quit
|
onHome = viewModel::quit
|
||||||
)
|
)
|
||||||
else -> {
|
TotPhase.PLAYING -> {
|
||||||
val question = state.questions[state.currentIndex]
|
val question = state.questions.getOrNull(state.currentIndex)
|
||||||
|
if (question == null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
|
} else {
|
||||||
val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
|
val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
|
||||||
ThisOrThatContent(
|
ThisOrThatContent(
|
||||||
question = question,
|
question = question,
|
||||||
|
|
@ -256,6 +393,7 @@ fun ThisOrThatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -554,12 +692,9 @@ private fun VersusBadge(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThisOrThatComplete(
|
private fun WaitingForRevealScreen(
|
||||||
aCount: Int,
|
partnerName: String,
|
||||||
bCount: Int,
|
onBack: () -> Unit
|
||||||
total: Int,
|
|
||||||
onPlayAgain: () -> Unit,
|
|
||||||
onHome: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -567,71 +702,45 @@ private fun ThisOrThatComplete(
|
||||||
.safeDrawingPadding()
|
.safeDrawingPadding()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(horizontal = 28.dp, vertical = 40.dp),
|
.padding(horizontal = 28.dp, vertical = 40.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
ChoiceCompleteBadge()
|
Surface(
|
||||||
|
modifier = Modifier.size(104.dp),
|
||||||
Column(
|
shape = CircleShape,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
color = CloserPalette.PinkMist,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
shadowElevation = 8.dp
|
||||||
) {
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = "All done!",
|
text = "✓",
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Your picks 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
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "You went through $total prompts.",
|
text = "We'll reveal how you two compare as soon as $partnerName finishes.",
|
||||||
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(4.dp))
|
||||||
|
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
|
||||||
if (total > 0) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
|
|
||||||
elevation = CardDefaults.cardElevation(6.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
TallyItem(label = "A", count = aCount, color = CloserPalette.PurpleDeep)
|
|
||||||
VerticalDivider(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(48.dp)
|
|
||||||
.width(1.dp),
|
|
||||||
color = Color(0xFFE8E0F0)
|
|
||||||
)
|
|
||||||
TallyItem(label = "B", count = bCount, color = CloserPalette.PinkAccentDeep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onPlayAgain,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 56.dp),
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
|
||||||
) {
|
|
||||||
Text("Play again", color = Color.White)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHome,
|
onClick = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 56.dp),
|
.heightIn(min = 56.dp),
|
||||||
|
|
@ -643,57 +752,202 @@ private fun ThisOrThatComplete(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChoiceCompleteBadge() {
|
private fun ThisOrThatReveal(
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "choice_complete")
|
matched: Int,
|
||||||
val pulse by infiniteTransition.animateFloat(
|
total: Int,
|
||||||
initialValue = 0.96f,
|
partnerName: String,
|
||||||
targetValue = 1.04f,
|
cards: List<RevealCard>,
|
||||||
animationSpec = infiniteRepeatable(
|
onPlayAgain: () -> Unit,
|
||||||
animation = tween(durationMillis = 1300),
|
onHome: () -> Unit
|
||||||
repeatMode = RepeatMode.Reverse
|
) {
|
||||||
),
|
val ratio = if (total > 0) matched.toFloat() / total else 0f
|
||||||
label = "choice_complete_pulse"
|
val headline = when {
|
||||||
)
|
ratio >= 0.8f -> "Two peas in a pod 🫛"
|
||||||
|
ratio >= 0.5f -> "Lots in common 💛"
|
||||||
|
ratio > 0f -> "Opposites attract ✨"
|
||||||
|
else -> "Total opposites 😄"
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(104.dp)
|
.fillMaxSize()
|
||||||
.scale(pulse),
|
.safeDrawingPadding()
|
||||||
shape = CircleShape,
|
.navigationBarsPadding(),
|
||||||
color = CloserPalette.PinkMist,
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||||
shadowElevation = 10.dp
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
item {
|
||||||
modifier = Modifier.background(CloserPalette.PurpleMist.copy(alpha = 0.42f)),
|
Column(
|
||||||
contentAlignment = Alignment.Center
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
|
MatchScoreBadge(matched = matched, total = total)
|
||||||
Text(
|
Text(
|
||||||
text = "A/B",
|
text = headline,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = CloserPalette.PurpleDeep,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = "You and $partnerName matched on $matched of $total.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(cards) { card ->
|
||||||
|
RevealRow(card = card, partnerName = partnerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onPlayAgain,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) {
|
||||||
|
Text("Play again", color = Color.White)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to Play")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TallyItem(label: String, count: Int, color: Color) {
|
private fun MatchScoreBadge(matched: Int, total: Int) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(116.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = CloserPalette.PurpleMist,
|
||||||
|
shadowElevation = 8.dp
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "$matched/$total",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "in sync",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = CloserPalette.PurpleDeep.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RevealRow(card: RevealCard, partnerName: String) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)),
|
||||||
|
elevation = CardDefaults.cardElevation(3.dp)
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "$count",
|
text = card.questionText,
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = color
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = if (card.agreed) CloserPalette.Evergreen.copy(alpha = 0.15f)
|
||||||
|
else CloserPalette.PinkMist
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (card.agreed) "Match" else "Differ",
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (card.agreed) CloserPalette.Evergreen else CloserPalette.PinkAccentDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
PickChip(
|
||||||
|
label = "You",
|
||||||
|
text = card.myText,
|
||||||
|
accent = CloserPalette.PurpleDeep,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
PickChip(
|
||||||
|
label = partnerName,
|
||||||
|
text = card.partnerText,
|
||||||
|
accent = CloserPalette.PinkAccentDeep,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PickChip(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
accent: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = accent.copy(alpha = 0.08f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = accent,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "picked $label",
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue