diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index fcb72130..ce92ac7e 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -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} ─────────── diff --git a/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt new file mode 100644 index 00000000..6191d7e2 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt @@ -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 = emptyList(), + val answersByUser: Map> = 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, + answers: List + ) { + 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 = 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 + 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 + ) + } +} diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt index 402542b3..7a388601 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt @@ -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 -> diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt index afc93a6f..04d639c4 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt @@ -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 = emptyList() + val categories: List = 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 = _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.") + } } } } diff --git a/app/src/main/java/app/closer/ui/wheel/LocalWheelSessionStore.kt b/app/src/main/java/app/closer/ui/wheel/LocalWheelSessionStore.kt index 4b94fbaf..35015cb0 100644 --- a/app/src/main/java/app/closer/ui/wheel/LocalWheelSessionStore.kt +++ b/app/src/main/java/app/closer/ui/wheel/LocalWheelSessionStore.kt @@ -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 } diff --git a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt index 0f24de75..e9bfd4e6 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt @@ -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 -> { diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index 246c28c7..46aa16b1 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -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 = 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 = _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, + 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 = {} + ) + } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt index 11bf8d59..56222843 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt @@ -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) diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index ab3adaaf..8d220f99 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + private var coupleId: String? = null + private var userId: String? = null + private val myAnswers = mutableListOf() + 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" + } }