feat: add Firestore wheel answer data source, update wheel screens with logging + answer history
This commit is contained in:
parent
408a2f24ba
commit
97cc334136
|
|
@ -33,6 +33,7 @@ object FirestoreCollections {
|
||||||
const val CHALLENGES = "challenges"
|
const val CHALLENGES = "challenges"
|
||||||
const val CAPSULES = "capsules"
|
const val CAPSULES = "capsules"
|
||||||
const val THIS_OR_THAT = "this_or_that"
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
|
const val WHEEL = "wheel"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
|
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 prompt in a wheel session, denormalized so the reveal needs no question reload. */
|
||||||
|
data class WheelQuestionRef(val id: String = "", val text: String = "")
|
||||||
|
|
||||||
|
/** One partner's answer to a prompt, pre-rendered to a display string at submit time. */
|
||||||
|
data class WheelAnswerEntry(val questionId: String = "", val display: String = "")
|
||||||
|
|
||||||
|
/** The full shared state of a wheel session's reveal. */
|
||||||
|
data class WheelRevealDoc(
|
||||||
|
val categoryName: String = "",
|
||||||
|
val questions: List<WheelQuestionRef> = emptyList(),
|
||||||
|
val answersByUser: Map<String, List<WheelAnswerEntry>> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores both partners' wheel answers for the async reveal at
|
||||||
|
* `couples/{coupleId}/wheel/{sessionId}`. 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 the same spun set on their own
|
||||||
|
* device and the result reveals once both have submitted.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore
|
||||||
|
) {
|
||||||
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.WHEEL)
|
||||||
|
.document(sessionId)
|
||||||
|
|
||||||
|
/** Persist this user's answers (display strings aligned to the question set). */
|
||||||
|
suspend fun submitAnswers(
|
||||||
|
coupleId: String,
|
||||||
|
sessionId: String,
|
||||||
|
userId: String,
|
||||||
|
categoryName: String,
|
||||||
|
questions: List<WheelQuestionRef>,
|
||||||
|
answers: List<WheelAnswerEntry>
|
||||||
|
) {
|
||||||
|
val data = mapOf(
|
||||||
|
"categoryName" to categoryName,
|
||||||
|
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
|
||||||
|
"answers" to mapOf(
|
||||||
|
userId to answers.map { mapOf("questionId" to it.questionId, "display" to it.display) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot read — used to detect whether this user has already answered. */
|
||||||
|
suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? =
|
||||||
|
runCatching { parse(doc(coupleId, sessionId).get().await()) }.getOrNull()
|
||||||
|
|
||||||
|
/** Live view of both partners' answers; emits whenever either side submits. */
|
||||||
|
fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow {
|
||||||
|
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
||||||
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
|
trySend(parse(snap))
|
||||||
|
}
|
||||||
|
awaitClose { reg.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(snap: DocumentSnapshot): WheelRevealDoc {
|
||||||
|
val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item ->
|
||||||
|
(item as? Map<*, *>)?.let {
|
||||||
|
WheelQuestionRef(
|
||||||
|
id = it["id"] as? String ?: "",
|
||||||
|
text = it["text"] as? String ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val rawAnswers = snap.get("answers") as? Map<String, *>
|
||||||
|
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
||||||
|
(value as? List<*>).orEmpty().mapNotNull { item ->
|
||||||
|
(item as? Map<*, *>)?.let {
|
||||||
|
WheelAnswerEntry(
|
||||||
|
questionId = it["questionId"] as? String ?: "",
|
||||||
|
display = it["display"] as? String ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WheelRevealDoc(
|
||||||
|
categoryName = snap.getString("categoryName") ?: "",
|
||||||
|
questions = questions,
|
||||||
|
answersByUser = answersByUser
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -51,6 +52,13 @@ fun CategoryPickerScreen(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(state.navigateTo) {
|
||||||
|
state.navigateTo?.let {
|
||||||
|
onNavigate(it)
|
||||||
|
viewModel.onNavigated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CategoryPickerContent(
|
CategoryPickerContent(
|
||||||
state = state,
|
state = state,
|
||||||
onCategorySelected = { item ->
|
onCategorySelected = { item ->
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ package app.closer.ui.wheel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class CategoryPickerItem(
|
data class CategoryPickerItem(
|
||||||
|
|
@ -22,25 +26,54 @@ data class CategoryPickerItem(
|
||||||
data class CategoryPickerUiState(
|
data class CategoryPickerUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val categories: List<CategoryPickerItem> = emptyList()
|
val categories: List<CategoryPickerItem> = emptyList(),
|
||||||
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CategoryPickerViewModel @Inject constructor(
|
class CategoryPickerViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val entitlementChecker: EntitlementChecker,
|
||||||
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CategoryPickerUiState())
|
private val _uiState = MutableStateFlow(CategoryPickerUiState())
|
||||||
val uiState: StateFlow<CategoryPickerUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<CategoryPickerUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
checkActiveSession()
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the couple already has a game in progress, don't let the user spin a new
|
||||||
|
* wheel: join the active wheel (so the partner answers the same spun set) or, for
|
||||||
|
* any other game, fall back to the waiting screen.
|
||||||
|
*/
|
||||||
|
private fun checkActiveSession() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val uid = gameSessionManager.currentUserId ?: return@launch
|
||||||
|
val couple = gameSessionManager.getCoupleForUser(uid) ?: return@launch
|
||||||
|
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }
|
||||||
|
.getOrNull() ?: return@launch
|
||||||
|
val target = if (active.gameType == GameType.WHEEL) {
|
||||||
|
AppRoute.wheelSession(active.id)
|
||||||
|
} else {
|
||||||
|
AppRoute.WAITING_FOR_PARTNER
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(navigateTo = target) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNavigated() {
|
||||||
|
_uiState.update { it.copy(navigateTo = null) }
|
||||||
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = CategoryPickerUiState(isLoading = true)
|
// Use copy() rather than full reassignment so a pending navigateTo from
|
||||||
|
// checkActiveSession isn't clobbered by the category load finishing.
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val hasPremium = entitlementChecker.isPremium().first()
|
val hasPremium = entitlementChecker.isPremium().first()
|
||||||
val items = repository.getCategories().map { category ->
|
val items = repository.getCategories().map { category ->
|
||||||
|
|
@ -50,12 +83,11 @@ class CategoryPickerViewModel @Inject constructor(
|
||||||
isLocked = category.access == "premium" && !hasPremium
|
isLocked = category.access == "premium" && !hasPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = CategoryPickerUiState(isLoading = false, categories = items)
|
_uiState.update { it.copy(isLoading = false, categories = items, error = null) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = CategoryPickerUiState(
|
_uiState.update {
|
||||||
isLoading = false,
|
it.copy(isLoading = false, error = e.message ?: "Could not load categories.")
|
||||||
error = e.message ?: "Could not load categories."
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ data class LocalWheelSession(
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class LocalWheelSessionStore @Inject constructor() {
|
class LocalWheelSessionStore @Inject constructor() {
|
||||||
|
/** The freshly-spun set, handed from the spin screen to the session screen. */
|
||||||
var activeSession: LocalWheelSession? = null
|
var activeSession: LocalWheelSession? = null
|
||||||
var sessionId: String? = null
|
|
||||||
var lastAnswered: Int = 0
|
|
||||||
var lastTotal: Int = 0
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,6 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
when {
|
when {
|
||||||
startResult.isSuccess -> {
|
startResult.isSuccess -> {
|
||||||
val sessionId = startResult.getOrThrow()
|
val sessionId = startResult.getOrThrow()
|
||||||
sessionStore.sessionId = sessionId
|
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelSession(sessionId)) }
|
_uiState.update { it.copy(navigateTo = AppRoute.wheelSession(sessionId)) }
|
||||||
}
|
}
|
||||||
startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true -> {
|
startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true -> {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
package app.closer.ui.wheel
|
package app.closer.ui.wheel
|
||||||
|
|
||||||
import app.closer.ui.theme.closerCardColor
|
import android.util.Log
|
||||||
import app.closer.ui.theme.closerBackgroundBrush
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import app.closer.core.navigation.AppRoute
|
|
||||||
import app.closer.domain.repository.AuthRepository
|
|
||||||
import app.closer.domain.repository.CoupleRepository
|
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.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.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
|
@ -28,14 +26,17 @@ import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -43,174 +44,374 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import app.closer.data.remote.FirestoreWheelAnswerDataSource
|
||||||
|
import app.closer.data.remote.WheelRevealDoc
|
||||||
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
import app.closer.ui.components.StatusGlyph
|
import app.closer.ui.components.StatusGlyph
|
||||||
import app.closer.ui.theme.CloserPalette
|
import app.closer.ui.theme.CloserPalette
|
||||||
|
import app.closer.ui.theme.closerBackgroundBrush
|
||||||
|
import app.closer.ui.theme.closerCardColor
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── ViewModel ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum class WheelRevealPhase { LOADING, WAITING, REVEAL }
|
||||||
|
|
||||||
|
/** One prompt with both partners' answers, ready to render side by side. */
|
||||||
|
data class WheelRevealItem(
|
||||||
|
val questionText: String,
|
||||||
|
val myDisplay: String,
|
||||||
|
val partnerDisplay: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WheelCompleteUiState(
|
||||||
|
val phase: WheelRevealPhase = WheelRevealPhase.LOADING,
|
||||||
|
val categoryName: String = "",
|
||||||
|
val partnerName: String = "Your partner",
|
||||||
|
val items: List<WheelRevealItem> = emptyList(),
|
||||||
|
val navigateTo: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class WheelCompleteViewModel @Inject constructor(
|
class WheelCompleteViewModel @Inject constructor(
|
||||||
private val sessionStore: LocalWheelSessionStore,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val authRepository: AuthRepository,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val answerDataSource: FirestoreWheelAnswerDataSource
|
||||||
private val gameSessionManager: GameSessionManager
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
|
|
||||||
val answered: Int = sessionStore.lastAnswered
|
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||||
val total: Int = sessionStore.lastTotal
|
|
||||||
|
private val _uiState = MutableStateFlow(WheelCompleteUiState())
|
||||||
|
val uiState: StateFlow<WheelCompleteUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var coupleId: String? = null
|
||||||
|
private var userId: String? = null
|
||||||
|
private var partnerId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
saveSession()
|
observe()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveSession() {
|
private fun observe() {
|
||||||
val session = sessionStore.activeSession ?: return
|
|
||||||
val sessionId = sessionStore.sessionId ?: return
|
|
||||||
val uid = authRepository.currentUserId ?: return
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch
|
val uid = gameSessionManager.currentUserId ?: return@launch
|
||||||
gameSessionManager.finishGame(
|
val couple = gameSessionManager.getCoupleForUser(uid) ?: return@launch
|
||||||
sessionId = sessionId,
|
userId = uid
|
||||||
coupleId = couple.id
|
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) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId.isBlank()) return@launch
|
||||||
|
answerDataSource.observe(couple.id, sessionId).collect { handle(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handle(doc: WheelRevealDoc) {
|
||||||
|
val uid = userId ?: return
|
||||||
|
val mine = doc.answersByUser[uid].orEmpty()
|
||||||
|
val theirs = partnerId?.let { doc.answersByUser[it] }.orEmpty()
|
||||||
|
|
||||||
|
if (mine.isNotEmpty() && theirs.isNotEmpty()) {
|
||||||
|
val items = doc.questions.map { q ->
|
||||||
|
WheelRevealItem(
|
||||||
|
questionText = q.text,
|
||||||
|
myDisplay = mine.firstOrNull { it.questionId == q.id }?.display ?: "—",
|
||||||
|
partnerDisplay = theirs.firstOrNull { it.questionId == q.id }?.display ?: "—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(phase = WheelRevealPhase.REVEAL, categoryName = doc.categoryName, items = items)
|
||||||
|
}
|
||||||
|
// Both have answered — release the one-game lock so a new game can start.
|
||||||
|
finishSession()
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(phase = WheelRevealPhase.WAITING, categoryName = doc.categoryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishSession() {
|
||||||
|
val cId = coupleId ?: return
|
||||||
|
if (sessionId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { gameSessionManager.finishGame(sessionId, cId) }
|
||||||
|
.onFailure { Log.d(TAG, "finishSession no-op: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WheelCompleteViewModel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Screen ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WheelCompleteScreen(
|
fun WheelCompleteScreen(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
onNavigate: (String) -> Unit = {},
|
onNavigate: (String) -> Unit = {},
|
||||||
viewModel: WheelCompleteViewModel = hiltViewModel()
|
viewModel: WheelCompleteViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
WheelCompleteContent(
|
val state by viewModel.uiState.collectAsState()
|
||||||
categoryName = viewModel.categoryName,
|
|
||||||
answered = viewModel.answered,
|
LaunchedEffect(state.navigateTo) {
|
||||||
total = viewModel.total,
|
state.navigateTo?.let {
|
||||||
onHome = { onNavigate(AppRoute.HOME) },
|
onNavigate(it)
|
||||||
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) }
|
viewModel.onNavigated()
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WheelCompleteContent(
|
|
||||||
categoryName: String,
|
|
||||||
answered: Int,
|
|
||||||
total: Int,
|
|
||||||
onHome: () -> Unit,
|
|
||||||
onSpinAgain: () -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(closerBackgroundBrush())
|
||||||
closerBackgroundBrush()
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Column(
|
when (state.phase) {
|
||||||
modifier = Modifier
|
WheelRevealPhase.LOADING -> CircularProgressIndicator(
|
||||||
.fillMaxSize()
|
modifier = Modifier.align(Alignment.Center),
|
||||||
.safeDrawingPadding()
|
color = CloserPalette.PurpleDeep
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = 28.dp, vertical = 40.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
StatusGlyph(
|
|
||||||
icon = Icons.Filled.Check,
|
|
||||||
tint = CloserPalette.PurpleDeep,
|
|
||||||
container = CloserPalette.PurpleMist,
|
|
||||||
size = 82.dp,
|
|
||||||
iconSize = 40.dp
|
|
||||||
)
|
)
|
||||||
|
WheelRevealPhase.WAITING -> WheelWaitingContent(
|
||||||
|
partnerName = state.partnerName,
|
||||||
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
WheelRevealPhase.REVEAL -> WheelRevealContent(
|
||||||
|
categoryName = state.categoryName,
|
||||||
|
partnerName = state.partnerName,
|
||||||
|
items = state.items,
|
||||||
|
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
|
||||||
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WheelWaitingContent(
|
||||||
|
partnerName: String,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 40.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
StatusGlyph(
|
||||||
|
icon = Icons.Filled.Check,
|
||||||
|
tint = CloserPalette.PurpleDeep,
|
||||||
|
container = CloserPalette.PurpleMist,
|
||||||
|
size = 82.dp,
|
||||||
|
iconSize = 40.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your answers are in!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "We'll reveal how you each answered as soon as $partnerName finishes.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to Play")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WheelRevealContent(
|
||||||
|
categoryName: String,
|
||||||
|
partnerName: String,
|
||||||
|
items: List<WheelRevealItem>,
|
||||||
|
onSpinAgain: () -> Unit,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
Column(
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
StatusGlyph(
|
||||||
|
icon = Icons.Filled.Check,
|
||||||
|
tint = CloserPalette.PurpleDeep,
|
||||||
|
container = CloserPalette.PurpleMist,
|
||||||
|
size = 72.dp,
|
||||||
|
iconSize = 34.dp
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Session complete",
|
text = "Here's how you each answered",
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
if (categoryName.isNotBlank()) {
|
if (categoryName.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = categoryName,
|
text = categoryName,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (total > 0) {
|
items(items) { item ->
|
||||||
Card(
|
WheelRevealRow(item = item, partnerName = partnerName)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(22.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "$answered",
|
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
color = Color(0xFFB98AF4)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "of $total questions",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
item {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(Modifier.height(8.dp))
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
Button(
|
||||||
|
onClick = onSpinAgain,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
) {
|
) {
|
||||||
Button(
|
Text("Spin again", color = Color.White)
|
||||||
onClick = onHome,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 56.dp),
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB98AF4))
|
|
||||||
) {
|
|
||||||
Text("Back home", color = Color.White)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onSpinAgain,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 56.dp),
|
|
||||||
shape = RoundedCornerShape(18.dp)
|
|
||||||
) {
|
|
||||||
Text("Spin again")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to Play")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WheelRevealRow(item: WheelRevealItem, partnerName: String) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)),
|
||||||
|
elevation = CardDefaults.cardElevation(3.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.questionText,
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
AnswerBlock(label = "You", text = item.myDisplay, accent = CloserPalette.PurpleDeep)
|
||||||
|
AnswerBlock(label = partnerName, text = item.partnerDisplay, accent = CloserPalette.PinkAccentDeep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnswerBlock(label: String, text: String, accent: Color) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = accent.copy(alpha = 0.08f)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = accent.copy(alpha = 0.16f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = accent,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun WheelCompleteScreenPreview() {
|
fun WheelRevealPreview() {
|
||||||
WheelCompleteContent(
|
Box(Modifier.background(Color(0xFFFFFBFE))) {
|
||||||
categoryName = "Trust",
|
WheelRevealContent(
|
||||||
answered = 8,
|
categoryName = "Trust",
|
||||||
total = 10,
|
partnerName = "Sam",
|
||||||
onHome = {},
|
items = listOf(
|
||||||
onSpinAgain = {}
|
WheelRevealItem(
|
||||||
)
|
questionText = "When did you last feel truly seen by me?",
|
||||||
|
myDisplay = "When you noticed I was off last Tuesday.",
|
||||||
|
partnerDisplay = "At dinner on Friday."
|
||||||
|
),
|
||||||
|
WheelRevealItem(
|
||||||
|
questionText = "Cozy night in or night out?",
|
||||||
|
myDisplay = "Cozy night in",
|
||||||
|
partnerDisplay = "Cozy night in"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onSpinAgain = {},
|
||||||
|
onHome = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CheckboxDefaults
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
|
@ -108,6 +109,16 @@ private fun WheelSessionContent(
|
||||||
.padding(horizontal = 24.dp, vertical = 24.dp),
|
.padding(horizontal = 24.dp, vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color(0xFF56306F))
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
EmptySessionCard()
|
EmptySessionCard()
|
||||||
return@Column
|
return@Column
|
||||||
|
|
@ -490,6 +501,7 @@ private fun EmptySessionCard() {
|
||||||
fun WheelSessionScreenPreview() {
|
fun WheelSessionScreenPreview() {
|
||||||
WheelSessionContent(
|
WheelSessionContent(
|
||||||
state = WheelSessionUiState(
|
state = WheelSessionUiState(
|
||||||
|
isLoading = false,
|
||||||
questions = listOf(
|
questions = listOf(
|
||||||
Question(id = "1", text = "When did you last feel truly seen by me?", category = "trust", depthLevel = 3),
|
Question(id = "1", text = "When did you last feel truly seen by me?", category = "trust", depthLevel = 3),
|
||||||
Question(id = "2", text = "What's one thing you wish we talked about more?", category = "communication", depthLevel = 2)
|
Question(id = "2", text = "What's one thing you wish we talked about more?", category = "communication", depthLevel = 2)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,29 @@
|
||||||
package app.closer.ui.wheel
|
package app.closer.ui.wheel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import app.closer.data.remote.FirestoreWheelAnswerDataSource
|
||||||
|
import app.closer.data.remote.WheelAnswerEntry
|
||||||
|
import app.closer.data.remote.WheelQuestionRef
|
||||||
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||||
import app.closer.domain.model.Question
|
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.GameSessionManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class WheelSessionUiState(
|
data class WheelSessionUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
val questions: List<Question> = emptyList(),
|
val questions: List<Question> = emptyList(),
|
||||||
val currentIndex: Int = 0,
|
val currentIndex: Int = 0,
|
||||||
val skippedCount: Int = 0,
|
val skippedCount: Int = 0,
|
||||||
|
|
@ -25,23 +38,70 @@ data class WheelSessionUiState(
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class WheelSessionViewModel @Inject constructor(
|
class WheelSessionViewModel @Inject constructor(
|
||||||
private val sessionStore: LocalWheelSessionStore
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val sessionStore: LocalWheelSessionStore,
|
||||||
|
private val repository: QuestionRepository,
|
||||||
|
private val gameSessionManager: GameSessionManager,
|
||||||
|
private val answerDataSource: FirestoreWheelAnswerDataSource
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(WheelSessionUiState())
|
private val _uiState = MutableStateFlow(WheelSessionUiState())
|
||||||
val uiState: StateFlow<WheelSessionUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<WheelSessionUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var coupleId: String? = null
|
||||||
|
private var userId: String? = null
|
||||||
|
private val myAnswers = mutableListOf<WheelAnswerEntry>()
|
||||||
|
private var submitting = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val session = sessionStore.activeSession
|
load()
|
||||||
if (session == null) {
|
}
|
||||||
_uiState.update { it.copy(isEmpty = true) }
|
|
||||||
} else {
|
private fun load() {
|
||||||
val firstQuestion = session.questions.firstOrNull()
|
viewModelScope.launch {
|
||||||
|
val uid = gameSessionManager.currentUserId
|
||||||
|
val couple = uid?.let { gameSessionManager.getCoupleForUser(it) }
|
||||||
|
if (uid == null || couple == null || sessionId.isBlank()) {
|
||||||
|
_uiState.update { it.copy(isLoading = false, isEmpty = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
userId = uid
|
||||||
|
coupleId = couple.id
|
||||||
|
|
||||||
|
// If I've already answered this set (e.g. re-opened while waiting), jump
|
||||||
|
// straight to the reveal instead of letting me answer a second time.
|
||||||
|
val existing = answerDataSource.getDoc(couple.id, sessionId)
|
||||||
|
if (existing?.answersByUser?.get(uid)?.isNotEmpty() == true) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, navigateTo = AppRoute.wheelComplete(sessionId))
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = gameSessionManager.getActiveSession(couple.id)
|
||||||
|
// Load the exact spun set from its fixed id list so both partners answer the
|
||||||
|
// identical wheel, in the same order, regardless of who spun.
|
||||||
|
val questionIds = session?.questionIds
|
||||||
|
?: sessionStore.activeSession?.questions?.map { it.id }.orEmpty()
|
||||||
|
val questions = questionIds.mapNotNull { repository.getQuestionById(it) }
|
||||||
|
val categoryName = sessionStore.activeSession?.categoryName
|
||||||
|
?: session?.categoryId
|
||||||
|
?.let { runCatching { repository.getCategoryById(it)?.displayName }.getOrNull() }
|
||||||
|
?: ""
|
||||||
|
|
||||||
|
if (questions.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(isLoading = false, isEmpty = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
questions = session.questions,
|
isLoading = false,
|
||||||
categoryName = session.categoryName,
|
questions = questions,
|
||||||
selectedScaleValue = defaultScaleValue(firstQuestion)
|
categoryName = categoryName,
|
||||||
|
selectedScaleValue = defaultScaleValue(questions.firstOrNull())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,10 +109,9 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun defaultScaleValue(question: Question?): Int {
|
private fun defaultScaleValue(question: Question?): Int {
|
||||||
val config = question?.answerConfig
|
val config = question?.answerConfig
|
||||||
if (config is app.closer.domain.model.ScaleAnswerConfigImpl) {
|
if (config is ScaleAnswerConfigImpl) {
|
||||||
val cfg = config.config
|
val cfg = config.config
|
||||||
val midpoint = (cfg.minScale + cfg.maxScale) / 2
|
val midpoint = (cfg.minScale + cfg.maxScale) / 2
|
||||||
// Clamp to valid range
|
|
||||||
return midpoint.coerceIn(cfg.minScale..cfg.maxScale)
|
return midpoint.coerceIn(cfg.minScale..cfg.maxScale)
|
||||||
}
|
}
|
||||||
return 3
|
return 3
|
||||||
|
|
@ -61,7 +120,7 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
fun selectOption(optionId: String) {
|
fun selectOption(optionId: String) {
|
||||||
val question = _uiState.value.questions.getOrNull(_uiState.value.currentIndex) ?: return
|
val question = _uiState.value.questions.getOrNull(_uiState.value.currentIndex) ?: return
|
||||||
if (question.type == "multi_choice") {
|
if (question.type == "multi_choice") {
|
||||||
val maxSel = (question.answerConfig as? app.closer.domain.model.ChoiceAnswerConfigImpl)
|
val maxSel = (question.answerConfig as? ChoiceAnswerConfigImpl)
|
||||||
?.config?.maxSelections ?: 0
|
?.config?.maxSelections ?: 0
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val current = it.selectedOptionIds.toMutableList()
|
val current = it.selectedOptionIds.toMutableList()
|
||||||
|
|
@ -87,41 +146,52 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val hasSelection = hasValidSelection(state)
|
val question = state.questions.getOrNull(state.currentIndex) ?: return
|
||||||
val nextIndex = state.currentIndex + 1
|
val answered = hasValidSelection(state)
|
||||||
|
recordAnswer(question.id, if (answered) displayFor(question, state) else SKIPPED)
|
||||||
if (nextIndex >= state.questions.size) {
|
advance(answered)
|
||||||
// Last question — finish
|
|
||||||
sessionStore.lastAnswered = state.answeredCount + (if (hasSelection) 1 else 0)
|
|
||||||
sessionStore.lastTotal = state.questions.size
|
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
|
|
||||||
} else {
|
|
||||||
val nextQuestion = state.questions.getOrNull(nextIndex)
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
currentIndex = nextIndex,
|
|
||||||
answeredCount = it.answeredCount + (if (hasSelection) 1 else 0),
|
|
||||||
selectedOptionIds = emptyList(),
|
|
||||||
selectedScaleValue = defaultScaleValue(nextQuestion),
|
|
||||||
writtenText = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun skip() {
|
fun skip() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val question = state.questions.getOrNull(state.currentIndex) ?: return
|
||||||
|
recordAnswer(question.id, SKIPPED)
|
||||||
|
advance(answered = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endEarly() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val question = state.questions.getOrNull(state.currentIndex)
|
||||||
|
if (question != null) {
|
||||||
|
val answered = hasValidSelection(state)
|
||||||
|
recordAnswer(question.id, if (answered) displayFor(question, state) else SKIPPED)
|
||||||
|
}
|
||||||
|
// Mark any prompts we never reached as skipped so both partners' answer lists
|
||||||
|
// line up with the shared question set.
|
||||||
|
for (i in myAnswers.size until state.questions.size) {
|
||||||
|
recordAnswer(state.questions[i].id, SKIPPED)
|
||||||
|
}
|
||||||
|
submitAndFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advance(answered: Boolean) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val nextIndex = state.currentIndex + 1
|
val nextIndex = state.currentIndex + 1
|
||||||
if (nextIndex >= state.questions.size) {
|
if (nextIndex >= state.questions.size) {
|
||||||
sessionStore.lastAnswered = state.answeredCount
|
_uiState.update {
|
||||||
sessionStore.lastTotal = state.questions.size
|
it.copy(
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
|
answeredCount = it.answeredCount + if (answered) 1 else 0,
|
||||||
|
skippedCount = it.skippedCount + if (answered) 0 else 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
submitAndFinish()
|
||||||
} else {
|
} else {
|
||||||
val nextQuestion = state.questions.getOrNull(nextIndex)
|
val nextQuestion = state.questions.getOrNull(nextIndex)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentIndex = nextIndex,
|
currentIndex = nextIndex,
|
||||||
skippedCount = state.skippedCount + 1,
|
answeredCount = it.answeredCount + if (answered) 1 else 0,
|
||||||
|
skippedCount = it.skippedCount + if (answered) 0 else 1,
|
||||||
selectedOptionIds = emptyList(),
|
selectedOptionIds = emptyList(),
|
||||||
selectedScaleValue = defaultScaleValue(nextQuestion),
|
selectedScaleValue = defaultScaleValue(nextQuestion),
|
||||||
writtenText = ""
|
writtenText = ""
|
||||||
|
|
@ -130,25 +200,75 @@ class WheelSessionViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endEarly() {
|
private fun recordAnswer(questionId: String, display: String) {
|
||||||
|
myAnswers.add(WheelAnswerEntry(questionId = questionId, display = display))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitAndFinish() {
|
||||||
|
if (submitting) return
|
||||||
|
submitting = true
|
||||||
|
val cId = coupleId
|
||||||
|
val uid = userId
|
||||||
|
if (cId == null || uid == null || sessionId.isBlank()) {
|
||||||
|
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
|
||||||
|
return
|
||||||
|
}
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
sessionStore.lastAnswered = state.answeredCount
|
val questionRefs = state.questions.map { WheelQuestionRef(it.id, it.text) }
|
||||||
sessionStore.lastTotal = state.questions.size
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
|
runCatching {
|
||||||
|
answerDataSource.submitAnswers(
|
||||||
|
coupleId = cId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
userId = uid,
|
||||||
|
categoryName = state.categoryName,
|
||||||
|
questions = questionRefs,
|
||||||
|
answers = myAnswers.toList()
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w(TAG, "Could not submit wheel answers", it) }
|
||||||
|
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete(sessionId)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasValidSelection(state: WheelSessionUiState): Boolean {
|
private fun hasValidSelection(state: WheelSessionUiState): Boolean {
|
||||||
val question = state.questions.getOrNull(state.currentIndex) ?: return false
|
val question = state.questions.getOrNull(state.currentIndex) ?: return false
|
||||||
return when (question.type) {
|
return when (question.type) {
|
||||||
"written" -> state.writtenText.isNotBlank()
|
"written" -> state.writtenText.isNotBlank()
|
||||||
"single_choice", "this_or_that" -> state.selectedOptionIds.isNotEmpty()
|
"single_choice", "this_or_that", "multi_choice" -> state.selectedOptionIds.isNotEmpty()
|
||||||
"multi_choice" -> state.selectedOptionIds.isNotEmpty()
|
|
||||||
"scale" -> true // always has a default value
|
"scale" -> true // always has a default value
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayFor(question: Question, state: WheelSessionUiState): String =
|
||||||
|
when (question.type) {
|
||||||
|
"written" -> state.writtenText.trim().ifBlank { SKIPPED }
|
||||||
|
"scale" -> state.selectedScaleValue.toString()
|
||||||
|
"single_choice", "this_or_that" ->
|
||||||
|
optionLabel(question, state.selectedOptionIds.firstOrNull()) ?: SKIPPED
|
||||||
|
"multi_choice" -> {
|
||||||
|
val labels = state.selectedOptionIds.mapNotNull { optionLabel(question, it) }
|
||||||
|
if (labels.isEmpty()) SKIPPED else labels.joinToString(", ")
|
||||||
|
}
|
||||||
|
else -> SKIPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionLabel(question: Question, optionId: String?): String? {
|
||||||
|
if (optionId == null) return null
|
||||||
|
return when (val cfg = question.answerConfig) {
|
||||||
|
is ChoiceAnswerConfigImpl -> cfg.config.options.firstOrNull { it.id == optionId }?.text
|
||||||
|
is ThisOrThatAnswerConfigImpl ->
|
||||||
|
listOf(cfg.config.optionA, cfg.config.optionB).firstOrNull { it.id == optionId }?.text
|
||||||
|
else -> null
|
||||||
|
} ?: optionId
|
||||||
|
}
|
||||||
|
|
||||||
fun onNavigated() {
|
fun onNavigated() {
|
||||||
_uiState.update { it.copy(navigateTo = null) }
|
_uiState.update { it.copy(navigateTo = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SKIPPED = "Skipped"
|
||||||
|
private const val TAG = "WheelSessionViewModel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue