diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt index a01ef091..19cc0c2c 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt @@ -16,11 +16,17 @@ fun DailyQuestionScreen( ) { val state by viewModel.uiState.collectAsState() - // Reveal is only offered once both partners have answered — the reveal screen enforces - // this too, but surfacing the button early is misleading and should be avoided. - val revealRoute: (() -> Unit)? = if (state.submitted && state.partnerHasAnswered) { + // The reveal route is available whenever both answered, and remains available after + // reveal so users can always navigate back to see both answers (labeled "View reveal"). + val revealAvailable = state.submitted && (state.partnerHasAnswered || state.isRevealed) + val revealRoute: (() -> Unit)? = if (revealAvailable) { state.question?.let { q -> { onNavigate(AppRoute.answerReveal(q.id)) } } } else null + val revealLabel = when { + state.isRevealed -> "View reveal" + revealRoute != null -> "Reveal" + else -> null + } LocalQuestionContent( state = state, @@ -36,7 +42,7 @@ fun DailyQuestionScreen( } }, onSecondaryRoute = revealRoute, - secondaryRouteLabel = if (revealRoute != null) "Reveal" else null, + secondaryRouteLabel = revealLabel, onWrittenTextChanged = viewModel::updateWrittenText, onOptionToggled = viewModel::toggleOption, onScaleChanged = viewModel::updateScale, diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index a14fc0f7..d98639fd 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -11,6 +11,8 @@ import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ListenerRegistration import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -26,6 +28,7 @@ data class LocalQuestionUiState( val coupleId: String? = null, val dailyQuestionDate: String? = null, val submitted: Boolean = false, + val isRevealed: Boolean = false, val pendingWrittenText: String = "", val pendingSelectedOptionIds: List = emptyList(), val pendingScaleValue: Int = 3, @@ -39,12 +42,21 @@ class DailyQuestionViewModel @Inject constructor( private val firestoreAnswerDataSource: FirestoreAnswerDataSource, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, - private val crashReporter: CrashReporter + private val crashReporter: CrashReporter, + private val db: FirebaseFirestore ) : ViewModel() { private val _uiState = MutableStateFlow(LocalQuestionUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var partnerAnswerListener: ListenerRegistration? = null + + override fun onCleared() { + super.onCleared() + partnerAnswerListener?.remove() + partnerAnswerListener = null + } + init { loadDailyQuestion() } @@ -67,6 +79,9 @@ class DailyQuestionViewModel @Inject constructor( pendingScaleValue = defaultScaleValue(question), partnerHasAnswered = partnerHasAnswered ).withLocalAnswer(answer) + + if (coupleId != null) startPartnerAnswerObserver(coupleId, today) + question?.let { observeLocalAnswerRevealed(it.id) } } catch (e: Exception) { crashReporter.recordException(e) _uiState.value = LocalQuestionUiState( @@ -77,6 +92,33 @@ class DailyQuestionViewModel @Inject constructor( } } + private fun startPartnerAnswerObserver(coupleId: String, date: String) { + viewModelScope.launch { + val userId = authRepository.currentUserId ?: return@launch + val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return@launch + val partnerId = couple.userIds.firstOrNull { it != userId } ?: return@launch + partnerAnswerListener?.remove() + partnerAnswerListener = db.collection("couples") + .document(coupleId) + .collection("daily_question") + .document(date) + .collection("answers") + .document(partnerId) + .addSnapshotListener { snapshot, error -> + if (error != null) return@addSnapshotListener + _uiState.update { it.copy(partnerHasAnswered = snapshot?.exists() == true) } + } + } + } + + private fun observeLocalAnswerRevealed(questionId: String) { + viewModelScope.launch { + localAnswerRepository.observeAnswer(questionId).collect { answer -> + _uiState.update { it.copy(isRevealed = answer?.isRevealed == true) } + } + } + } + /** * Resolves the current couple (if any) and the daily question. * diff --git a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt index 727ac243..767ce508 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt @@ -6,9 +6,10 @@ import app.closer.domain.model.Question import app.closer.domain.model.ThisOrThatAnswerConfigImpl fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState { - answer ?: return copy(submitted = false) + answer ?: return copy(submitted = false, isRevealed = false) return copy( submitted = true, + isRevealed = answer.isRevealed, pendingWrittenText = answer.writtenText.orEmpty(), pendingSelectedOptionIds = answer.selectedOptionIds, pendingScaleValue = answer.scaleValue ?: pendingScaleValue diff --git a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt index 4d72b0d5..8436607f 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt @@ -257,6 +257,12 @@ private fun SubmittedAnswerCard( question: Question, state: LocalQuestionUiState ) { + val badge = if (state.isRevealed) "Revealed" else "OK" + val label = when { + state.isRevealed -> "Answer revealed" + !state.partnerHasAnswered -> "Private answer saved — waiting for partner" + else -> "Private answer saved" + } Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(22.dp), @@ -276,7 +282,7 @@ private fun SubmittedAnswerCard( contentAlignment = Alignment.Center ) { Text( - text = "OK", + text = badge, style = MaterialTheme.typography.labelSmall, color = Color(0xFF56306F), fontWeight = FontWeight.Bold @@ -284,7 +290,7 @@ private fun SubmittedAnswerCard( } Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( - text = "Private answer saved", + text = label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt index 9bbd901f..76ec10cb 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt @@ -1,12 +1,54 @@ package app.closer.ui.questions +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +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.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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute import app.closer.domain.model.Question +import app.closer.domain.model.QuestionAnswer +import app.closer.ui.components.ErrorState +import app.closer.ui.components.LoadingState +import app.closer.ui.questions.components.AnswerBubble +import app.closer.ui.questions.components.QuestionAnswerInput +import app.closer.ui.questions.components.QuestionDiscussionThread +import app.closer.ui.questions.components.QuestionHeader +import app.closer.ui.theme.closerBackgroundBrush @Composable fun QuestionThreadScreen( @@ -16,72 +58,262 @@ fun QuestionThreadScreen( nextQuestionId: String? = null, onNavigate: (String) -> Unit = {}, onBack: () -> Unit = {}, - viewModel: QuestionDetailViewModel = hiltViewModel() + viewModel: QuestionThreadViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() - LocalQuestionContent( - state = state, - title = "Answer with care", - subtitle = "Save your answer privately first. From there, choose whether to keep moving or revisit what you have opened.", - primaryRouteLabel = nextQuestionId?.let { "Next prompt" } ?: "Saved answers", - onPrimaryRoute = { - if (nextQuestionId != null) { - onNavigate( - AppRoute.questionThread( - coupleId = coupleId, - questionId = nextQuestionId, - prevId = questionId - ) - ) - } else { - onNavigate(AppRoute.ANSWER_HISTORY) - } - }, - onSecondaryRoute = previousQuestionId?.let { - { - onNavigate( - AppRoute.questionThread( - coupleId = coupleId, - questionId = previousQuestionId, - nextId = questionId - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(closerBackgroundBrush()) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - } ?: onBack, - secondaryRouteLabel = previousQuestionId?.let { "Previous" } ?: "Back", - onWrittenTextChanged = viewModel::updateWrittenText, - onOptionToggled = viewModel::toggleOption, - onScaleChanged = viewModel::updateScale, - onSubmit = viewModel::submitAnswer, - canSubmit = viewModel.canSubmit() - ) + + when { + state.isLoading -> LoadingState(message = "Opening thread") + state.error != null -> ErrorState( + title = "Thread unavailable", + message = state.error ?: "Something went wrong." + ) + state.question == null -> LoadingState(message = "Loading question") + else -> { + val question = state.question!! + + QuestionHeader( + question = question, + helpExpanded = state.helpExpanded, + onToggleHelp = viewModel::toggleHelp + ) + + when (state.phase) { + QuestionPhase.INPUT -> InputPhase( + question = question, + state = state, + viewModel = viewModel + ) + QuestionPhase.WAITING -> WaitingPhase( + myAnswer = state.myAnswer, + question = question + ) + QuestionPhase.REVEALED -> AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 4 } + ) { + RevealedPhase( + state = state, + question = question, + viewModel = viewModel, + coupleId = coupleId, + questionId = questionId, + previousQuestionId = previousQuestionId, + nextQuestionId = nextQuestionId, + onNavigate = onNavigate + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } } -@Preview @Composable -fun QuestionThreadScreenPreview() { - LocalQuestionContent( - state = LocalQuestionUiState( - isLoading = false, - question = Question( - id = "demo", - text = "What is one conversation you want us to handle more gently?", - category = "communication", - depthLevel = 3, - type = "written" - ) - ), - title = "Answer with care", - subtitle = "Save your answer privately first.", - primaryRouteLabel = "Saved answers", - onPrimaryRoute = {}, - onSecondaryRoute = {}, - secondaryRouteLabel = "Back", - onWrittenTextChanged = {}, - onOptionToggled = {}, - onScaleChanged = {}, - onSubmit = {}, - canSubmit = false - ) +private fun InputPhase( + question: Question, + state: QuestionThreadUiState, + viewModel: QuestionThreadViewModel +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + text = "Answer privately. Your partner won't see this until they answer too.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + QuestionAnswerInput( + question = question, + pendingWrittenText = state.pendingWrittenText, + pendingSelectedOptionIds = state.pendingSelectedOptionIds, + pendingScaleValue = state.pendingScaleValue, + onWrittenTextChanged = viewModel::updateWrittenText, + onOptionToggled = viewModel::toggleOption, + onScaleChanged = viewModel::updateScale, + onSubmit = viewModel::submitAnswer, + canSubmit = viewModel.canSubmit(), + isSubmitting = state.isSubmitting + ) + } +} + +@Composable +private fun WaitingPhase( + myAnswer: QuestionAnswer?, + question: Question +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.White.copy(alpha = 0.78f) + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Your answer is saved", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (myAnswer != null) { + Text( + text = answerPreviewText(myAnswer, question), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = "Waiting for your partner to answer. The discussion opens once you've both responded.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +private fun RevealedPhase( + state: QuestionThreadUiState, + question: Question, + viewModel: QuestionThreadViewModel, + coupleId: String, + questionId: String, + previousQuestionId: String?, + nextQuestionId: String?, + onNavigate: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + state.myAnswer?.let { myAnswer -> + AnswerBubble( + answer = myAnswer, + question = question, + isCurrentUser = true, + partnerDisplayName = null, + reactions = state.reactions.filter { it.targetUserId == viewModel.currentUserId }, + onAddReaction = { emoji -> + viewModel.addReaction(viewModel.currentUserId, emoji) + } + ) + } + state.partnerAnswer?.let { partnerAnswer -> + AnswerBubble( + answer = partnerAnswer, + question = question, + isCurrentUser = false, + partnerDisplayName = null, + reactions = state.reactions.filter { it.targetUserId == partnerAnswer.userId }, + onAddReaction = { emoji -> + viewModel.addReaction(partnerAnswer.userId, emoji) + } + ) + } + + QuestionDiscussionThread( + messages = state.messages, + currentUserId = viewModel.currentUserId, + messageInput = state.messageInput, + onMessageInputChanged = viewModel::updateMessageInput, + onSendMessage = viewModel::sendMessage, + isRevealed = true + ) + + // Navigation out of the thread + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (nextQuestionId != null) { + Button( + onClick = { + onNavigate( + AppRoute.questionThread( + coupleId = coupleId, + questionId = nextQuestionId, + prevId = questionId + ) + ) + }, + modifier = Modifier.weight(1f).heightIn(min = 48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFB98AF4), + contentColor = Color(0xFF24122F) + ) + ) { + Text("Next prompt") + } + } + OutlinedButton( + onClick = { onNavigate(AppRoute.ANSWER_HISTORY) }, + modifier = Modifier.weight(1f).heightIn(min = 48.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text("Saved answers") + } + } + + if (previousQuestionId != null) { + OutlinedButton( + onClick = { + onNavigate( + AppRoute.questionThread( + coupleId = coupleId, + questionId = previousQuestionId, + nextId = questionId + ) + ) + }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text("Previous prompt") + } + } + } +} + +private fun answerPreviewText(answer: QuestionAnswer, question: Question): String { + return when (question.type) { + "written" -> answer.writtenText?.take(120) ?: "Your written answer." + "scale" -> "Scale: ${answer.scaleValue}" + "single_choice", "this_or_that" -> { + val cfg = (question.answerConfig as? app.closer.domain.model.ChoiceAnswerConfigImpl)?.config + val id = answer.selectedOptionIds.firstOrNull() ?: return "Your choice." + cfg?.options?.find { it.id == id }?.text ?: "Your choice." + } + "multi_choice" -> { + val cfg = (question.answerConfig as? app.closer.domain.model.ChoiceAnswerConfigImpl)?.config + answer.selectedOptionIds.mapNotNull { id -> + cfg?.options?.find { it.id == id }?.text + }.joinToString(", ").ifBlank { "Your choices." } + } + else -> "Your answer is saved." + } }