feat: add Firestore wheel answer data source, update wheel screens with logging + answer history

This commit is contained in:
null 2026-06-18 20:10:08 -05:00
parent 408a2f24ba
commit 97cc334136
9 changed files with 660 additions and 187 deletions

View File

@ -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} ───────────

View File

@ -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
)
}
}

View File

@ -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 ->

View File

@ -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.")
}
}
}
}

View File

@ -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
}

View File

@ -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 -> {

View File

@ -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 = {}
)
}
}

View File

@ -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)

View File

@ -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"
}
}