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

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

View File

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

View File

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

View File

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

View File

@ -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,37 +44,125 @@ 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(
@ -81,30 +170,44 @@ fun WheelCompleteScreen(
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() ) {
), when (state.phase) {
contentAlignment = Alignment.Center 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( Column(
modifier = Modifier modifier = Modifier
@ -112,9 +215,11 @@ private fun WheelCompleteContent(
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp), .padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(Modifier.weight(1f))
StatusGlyph( StatusGlyph(
icon = Icons.Filled.Check, icon = Icons.Filled.Check,
tint = CloserPalette.PurpleDeep, tint = CloserPalette.PurpleDeep,
@ -122,95 +227,191 @@ private fun WheelCompleteContent(
size = 82.dp, size = 82.dp,
iconSize = 40.dp iconSize = 40.dp
) )
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text( Text(
text = "Session complete", text = "Your answers are in!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
if (categoryName.isNotBlank()) {
Text( Text(
text = categoryName, text = "We'll reveal how you each answered as soon as $partnerName finishes.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} Spacer(Modifier.height(4.dp))
} CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
if (total > 0) { Spacer(Modifier.weight(1f))
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
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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( OutlinedButton(
onClick = onSpinAgain, onClick = onHome,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 56.dp), .heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
) { ) {
Text("Spin again") 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(8.dp)
) {
StatusGlyph(
icon = Icons.Filled.Check,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist,
size = 72.dp,
iconSize = 34.dp
)
Text(
text = "Here's how you each answered",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
if (categoryName.isNotBlank()) {
Text(
text = categoryName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
}
}
items(items) { item ->
WheelRevealRow(item = item, partnerName = partnerName)
}
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)
) {
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 @Preview
@Composable @Composable
fun WheelCompleteScreenPreview() { fun WheelRevealPreview() {
WheelCompleteContent( Box(Modifier.background(Color(0xFFFFFBFE))) {
WheelRevealContent(
categoryName = "Trust", categoryName = "Trust",
answered = 8, partnerName = "Sam",
total = 10, items = listOf(
onHome = {}, WheelRevealItem(
onSpinAgain = {} 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.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)

View File

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