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 CAPSULES = "capsules"
|
||||
const val THIS_OR_THAT = "this_or_that"
|
||||
const val WHEEL = "wheel"
|
||||
}
|
||||
|
||||
// ── 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.Text
|
||||
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
|
||||
|
|
@ -51,6 +52,13 @@ fun CategoryPickerScreen(
|
|||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(state.navigateTo) {
|
||||
state.navigateTo?.let {
|
||||
onNavigate(it)
|
||||
viewModel.onNavigated()
|
||||
}
|
||||
}
|
||||
|
||||
CategoryPickerContent(
|
||||
state = state,
|
||||
onCategorySelected = { item ->
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ package app.closer.ui.wheel
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.repository.QuestionRepository
|
||||
import app.closer.domain.usecase.GameSessionManager
|
||||
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.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CategoryPickerItem(
|
||||
|
|
@ -22,25 +26,54 @@ data class CategoryPickerItem(
|
|||
data class CategoryPickerUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val categories: List<CategoryPickerItem> = emptyList()
|
||||
val categories: List<CategoryPickerItem> = emptyList(),
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CategoryPickerViewModel @Inject constructor(
|
||||
private val repository: QuestionRepository,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val entitlementChecker: EntitlementChecker,
|
||||
private val gameSessionManager: GameSessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(CategoryPickerUiState())
|
||||
val uiState: StateFlow<CategoryPickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
checkActiveSession()
|
||||
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() {
|
||||
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 {
|
||||
val hasPremium = entitlementChecker.isPremium().first()
|
||||
val items = repository.getCategories().map { category ->
|
||||
|
|
@ -50,12 +83,11 @@ class CategoryPickerViewModel @Inject constructor(
|
|||
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) {
|
||||
_uiState.value = CategoryPickerUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Could not load categories."
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = e.message ?: "Could not load categories.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ data class LocalWheelSession(
|
|||
|
||||
@Singleton
|
||||
class LocalWheelSessionStore @Inject constructor() {
|
||||
/** The freshly-spun set, handed from the spin screen to the session screen. */
|
||||
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 {
|
||||
startResult.isSuccess -> {
|
||||
val sessionId = startResult.getOrThrow()
|
||||
sessionStore.sessionId = sessionId
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelSession(sessionId)) }
|
||||
}
|
||||
startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true -> {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
package app.closer.ui.wheel
|
||||
|
||||
import app.closer.ui.theme.closerCardColor
|
||||
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 android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.material.icons.Icons
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.unit.dp
|
||||
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.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
|
||||
class WheelCompleteViewModel @Inject constructor(
|
||||
private val sessionStore: LocalWheelSessionStore,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val gameSessionManager: GameSessionManager
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val answerDataSource: FirestoreWheelAnswerDataSource
|
||||
) : ViewModel() {
|
||||
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
|
||||
val answered: Int = sessionStore.lastAnswered
|
||||
val total: Int = sessionStore.lastTotal
|
||||
|
||||
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||
|
||||
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 {
|
||||
saveSession()
|
||||
observe()
|
||||
}
|
||||
|
||||
private fun saveSession() {
|
||||
val session = sessionStore.activeSession ?: return
|
||||
val sessionId = sessionStore.sessionId ?: return
|
||||
val uid = authRepository.currentUserId ?: return
|
||||
private fun observe() {
|
||||
viewModelScope.launch {
|
||||
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch
|
||||
gameSessionManager.finishGame(
|
||||
sessionId = sessionId,
|
||||
coupleId = couple.id
|
||||
)
|
||||
val uid = gameSessionManager.currentUserId ?: return@launch
|
||||
val couple = gameSessionManager.getCoupleForUser(uid) ?: return@launch
|
||||
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) } }
|
||||
}
|
||||
|
||||
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
|
||||
fun WheelCompleteScreen(
|
||||
sessionId: String,
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: WheelCompleteViewModel = hiltViewModel()
|
||||
) {
|
||||
WheelCompleteContent(
|
||||
categoryName = viewModel.categoryName,
|
||||
answered = viewModel.answered,
|
||||
total = viewModel.total,
|
||||
onHome = { onNavigate(AppRoute.HOME) },
|
||||
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) }
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(state.navigateTo) {
|
||||
state.navigateTo?.let {
|
||||
onNavigate(it)
|
||||
viewModel.onNavigated()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WheelCompleteContent(
|
||||
categoryName: String,
|
||||
answered: Int,
|
||||
total: Int,
|
||||
onHome: () -> Unit,
|
||||
onSpinAgain: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
closerBackgroundBrush()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
.background(closerBackgroundBrush())
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.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
|
||||
when (state.phase) {
|
||||
WheelRevealPhase.LOADING -> CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = CloserPalette.PurpleDeep
|
||||
)
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 = "Session complete",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
text = "Here's how you each answered",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (categoryName.isNotBlank()) {
|
||||
Text(
|
||||
text = categoryName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
Card(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(items) { item ->
|
||||
WheelRevealRow(item = item, partnerName = partnerName)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onSpinAgain,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||
) {
|
||||
Button(
|
||||
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")
|
||||
}
|
||||
Text("Spin 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
|
||||
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
|
||||
@Composable
|
||||
fun WheelCompleteScreenPreview() {
|
||||
WheelCompleteContent(
|
||||
categoryName = "Trust",
|
||||
answered = 8,
|
||||
total = 10,
|
||||
onHome = {},
|
||||
onSpinAgain = {}
|
||||
)
|
||||
fun WheelRevealPreview() {
|
||||
Box(Modifier.background(Color(0xFFFFFBFE))) {
|
||||
WheelRevealContent(
|
||||
categoryName = "Trust",
|
||||
partnerName = "Sam",
|
||||
items = listOf(
|
||||
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.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
|
|
@ -108,6 +109,16 @@ private fun WheelSessionContent(
|
|||
.padding(horizontal = 24.dp, vertical = 24.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) {
|
||||
EmptySessionCard()
|
||||
return@Column
|
||||
|
|
@ -490,6 +501,7 @@ private fun EmptySessionCard() {
|
|||
fun WheelSessionScreenPreview() {
|
||||
WheelSessionContent(
|
||||
state = WheelSessionUiState(
|
||||
isLoading = false,
|
||||
questions = listOf(
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
package app.closer.ui.wheel
|
||||
|
||||
import android.util.Log
|
||||
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.WheelAnswerEntry
|
||||
import app.closer.data.remote.WheelQuestionRef
|
||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||
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 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
|
||||
|
||||
data class WheelSessionUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val questions: List<Question> = emptyList(),
|
||||
val currentIndex: Int = 0,
|
||||
val skippedCount: Int = 0,
|
||||
|
|
@ -25,23 +38,70 @@ data class WheelSessionUiState(
|
|||
|
||||
@HiltViewModel
|
||||
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() {
|
||||
|
||||
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(WheelSessionUiState())
|
||||
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 {
|
||||
val session = sessionStore.activeSession
|
||||
if (session == null) {
|
||||
_uiState.update { it.copy(isEmpty = true) }
|
||||
} else {
|
||||
val firstQuestion = session.questions.firstOrNull()
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
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 {
|
||||
it.copy(
|
||||
questions = session.questions,
|
||||
categoryName = session.categoryName,
|
||||
selectedScaleValue = defaultScaleValue(firstQuestion)
|
||||
isLoading = false,
|
||||
questions = questions,
|
||||
categoryName = categoryName,
|
||||
selectedScaleValue = defaultScaleValue(questions.firstOrNull())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,10 +109,9 @@ class WheelSessionViewModel @Inject constructor(
|
|||
|
||||
private fun defaultScaleValue(question: Question?): Int {
|
||||
val config = question?.answerConfig
|
||||
if (config is app.closer.domain.model.ScaleAnswerConfigImpl) {
|
||||
if (config is ScaleAnswerConfigImpl) {
|
||||
val cfg = config.config
|
||||
val midpoint = (cfg.minScale + cfg.maxScale) / 2
|
||||
// Clamp to valid range
|
||||
return midpoint.coerceIn(cfg.minScale..cfg.maxScale)
|
||||
}
|
||||
return 3
|
||||
|
|
@ -61,7 +120,7 @@ class WheelSessionViewModel @Inject constructor(
|
|||
fun selectOption(optionId: String) {
|
||||
val question = _uiState.value.questions.getOrNull(_uiState.value.currentIndex) ?: return
|
||||
if (question.type == "multi_choice") {
|
||||
val maxSel = (question.answerConfig as? app.closer.domain.model.ChoiceAnswerConfigImpl)
|
||||
val maxSel = (question.answerConfig as? ChoiceAnswerConfigImpl)
|
||||
?.config?.maxSelections ?: 0
|
||||
_uiState.update {
|
||||
val current = it.selectedOptionIds.toMutableList()
|
||||
|
|
@ -87,41 +146,52 @@ class WheelSessionViewModel @Inject constructor(
|
|||
|
||||
fun next() {
|
||||
val state = _uiState.value
|
||||
val hasSelection = hasValidSelection(state)
|
||||
val nextIndex = state.currentIndex + 1
|
||||
|
||||
if (nextIndex >= state.questions.size) {
|
||||
// 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 = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
val question = state.questions.getOrNull(state.currentIndex) ?: return
|
||||
val answered = hasValidSelection(state)
|
||||
recordAnswer(question.id, if (answered) displayFor(question, state) else SKIPPED)
|
||||
advance(answered)
|
||||
}
|
||||
|
||||
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 nextIndex = state.currentIndex + 1
|
||||
if (nextIndex >= state.questions.size) {
|
||||
sessionStore.lastAnswered = state.answeredCount
|
||||
sessionStore.lastTotal = state.questions.size
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
answeredCount = it.answeredCount + if (answered) 1 else 0,
|
||||
skippedCount = it.skippedCount + if (answered) 0 else 1
|
||||
)
|
||||
}
|
||||
submitAndFinish()
|
||||
} else {
|
||||
val nextQuestion = state.questions.getOrNull(nextIndex)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentIndex = nextIndex,
|
||||
skippedCount = state.skippedCount + 1,
|
||||
answeredCount = it.answeredCount + if (answered) 1 else 0,
|
||||
skippedCount = it.skippedCount + if (answered) 0 else 1,
|
||||
selectedOptionIds = emptyList(),
|
||||
selectedScaleValue = defaultScaleValue(nextQuestion),
|
||||
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
|
||||
sessionStore.lastAnswered = state.answeredCount
|
||||
sessionStore.lastTotal = state.questions.size
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
|
||||
val questionRefs = state.questions.map { WheelQuestionRef(it.id, it.text) }
|
||||
viewModelScope.launch {
|
||||
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 {
|
||||
val question = state.questions.getOrNull(state.currentIndex) ?: return false
|
||||
return when (question.type) {
|
||||
"written" -> state.writtenText.isNotBlank()
|
||||
"single_choice", "this_or_that" -> state.selectedOptionIds.isNotEmpty()
|
||||
"multi_choice" -> state.selectedOptionIds.isNotEmpty()
|
||||
"single_choice", "this_or_that", "multi_choice" -> state.selectedOptionIds.isNotEmpty()
|
||||
"scale" -> true // always has a default value
|
||||
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() {
|
||||
_uiState.update { it.copy(navigateTo = null) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SKIPPED = "Skipped"
|
||||
private const val TAG = "WheelSessionViewModel"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue