refactor(android): update question thread and answer mapping patterns

This commit is contained in:
null 2026-06-20 18:02:21 -05:00
parent eebdee6120
commit 5a2bdf7b0f
5 changed files with 356 additions and 69 deletions

View File

@ -16,11 +16,17 @@ fun DailyQuestionScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
// Reveal is only offered once both partners have answered — the reveal screen enforces // The reveal route is available whenever both answered, and remains available after
// this too, but surfacing the button early is misleading and should be avoided. // reveal so users can always navigate back to see both answers (labeled "View reveal").
val revealRoute: (() -> Unit)? = if (state.submitted && state.partnerHasAnswered) { val revealAvailable = state.submitted && (state.partnerHasAnswered || state.isRevealed)
val revealRoute: (() -> Unit)? = if (revealAvailable) {
state.question?.let { q -> { onNavigate(AppRoute.answerReveal(q.id)) } } state.question?.let { q -> { onNavigate(AppRoute.answerReveal(q.id)) } }
} else null } else null
val revealLabel = when {
state.isRevealed -> "View reveal"
revealRoute != null -> "Reveal"
else -> null
}
LocalQuestionContent( LocalQuestionContent(
state = state, state = state,
@ -36,7 +42,7 @@ fun DailyQuestionScreen(
} }
}, },
onSecondaryRoute = revealRoute, onSecondaryRoute = revealRoute,
secondaryRouteLabel = if (revealRoute != null) "Reveal" else null, secondaryRouteLabel = revealLabel,
onWrittenTextChanged = viewModel::updateWrittenText, onWrittenTextChanged = viewModel::updateWrittenText,
onOptionToggled = viewModel::toggleOption, onOptionToggled = viewModel::toggleOption,
onScaleChanged = viewModel::updateScale, onScaleChanged = viewModel::updateScale,

View File

@ -11,6 +11,8 @@ import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -26,6 +28,7 @@ data class LocalQuestionUiState(
val coupleId: String? = null, val coupleId: String? = null,
val dailyQuestionDate: String? = null, val dailyQuestionDate: String? = null,
val submitted: Boolean = false, val submitted: Boolean = false,
val isRevealed: Boolean = false,
val pendingWrittenText: String = "", val pendingWrittenText: String = "",
val pendingSelectedOptionIds: List<String> = emptyList(), val pendingSelectedOptionIds: List<String> = emptyList(),
val pendingScaleValue: Int = 3, val pendingScaleValue: Int = 3,
@ -39,12 +42,21 @@ class DailyQuestionViewModel @Inject constructor(
private val firestoreAnswerDataSource: FirestoreAnswerDataSource, private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter private val crashReporter: CrashReporter,
private val db: FirebaseFirestore
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LocalQuestionUiState()) private val _uiState = MutableStateFlow(LocalQuestionUiState())
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow() val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
private var partnerAnswerListener: ListenerRegistration? = null
override fun onCleared() {
super.onCleared()
partnerAnswerListener?.remove()
partnerAnswerListener = null
}
init { init {
loadDailyQuestion() loadDailyQuestion()
} }
@ -67,6 +79,9 @@ class DailyQuestionViewModel @Inject constructor(
pendingScaleValue = defaultScaleValue(question), pendingScaleValue = defaultScaleValue(question),
partnerHasAnswered = partnerHasAnswered partnerHasAnswered = partnerHasAnswered
).withLocalAnswer(answer) ).withLocalAnswer(answer)
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
question?.let { observeLocalAnswerRevealed(it.id) }
} catch (e: Exception) { } catch (e: Exception) {
crashReporter.recordException(e) crashReporter.recordException(e)
_uiState.value = LocalQuestionUiState( _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. * Resolves the current couple (if any) and the daily question.
* *

View File

@ -6,9 +6,10 @@ import app.closer.domain.model.Question
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState { fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
answer ?: return copy(submitted = false) answer ?: return copy(submitted = false, isRevealed = false)
return copy( return copy(
submitted = true, submitted = true,
isRevealed = answer.isRevealed,
pendingWrittenText = answer.writtenText.orEmpty(), pendingWrittenText = answer.writtenText.orEmpty(),
pendingSelectedOptionIds = answer.selectedOptionIds, pendingSelectedOptionIds = answer.selectedOptionIds,
pendingScaleValue = answer.scaleValue ?: pendingScaleValue pendingScaleValue = answer.scaleValue ?: pendingScaleValue

View File

@ -257,6 +257,12 @@ private fun SubmittedAnswerCard(
question: Question, question: Question,
state: LocalQuestionUiState 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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
@ -276,7 +282,7 @@ private fun SubmittedAnswerCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "OK", text = badge,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Color(0xFF56306F), color = Color(0xFF56306F),
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@ -284,7 +290,7 @@ private fun SubmittedAnswerCard(
} }
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text( Text(
text = "Private answer saved", text = label,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold

View File

@ -1,12 +1,54 @@
package app.closer.ui.questions 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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 androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Question 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 @Composable
fun QuestionThreadScreen( fun QuestionThreadScreen(
@ -16,72 +58,262 @@ fun QuestionThreadScreen(
nextQuestionId: String? = null, nextQuestionId: String? = null,
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
onBack: () -> Unit = {}, onBack: () -> Unit = {},
viewModel: QuestionDetailViewModel = hiltViewModel() viewModel: QuestionThreadViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
LocalQuestionContent( Box(
state = state, modifier = Modifier
title = "Answer with care", .fillMaxSize()
subtitle = "Save your answer privately first. From there, choose whether to keep moving or revisit what you have opened.", .background(closerBackgroundBrush())
primaryRouteLabel = nextQuestionId?.let { "Next prompt" } ?: "Saved answers", ) {
onPrimaryRoute = { Column(
if (nextQuestionId != null) { modifier = Modifier
onNavigate( .fillMaxSize()
AppRoute.questionThread( .safeDrawingPadding()
coupleId = coupleId, .navigationBarsPadding()
questionId = nextQuestionId, .verticalScroll(rememberScrollState())
prevId = questionId .padding(horizontal = 20.dp, vertical = 18.dp),
) verticalArrangement = Arrangement.spacedBy(18.dp)
) ) {
} else { IconButton(onClick = onBack) {
onNavigate(AppRoute.ANSWER_HISTORY) Icon(
} imageVector = Icons.AutoMirrored.Filled.ArrowBack,
}, contentDescription = "Back"
onSecondaryRoute = previousQuestionId?.let {
{
onNavigate(
AppRoute.questionThread(
coupleId = coupleId,
questionId = previousQuestionId,
nextId = questionId
)
) )
} }
} ?: onBack,
secondaryRouteLabel = previousQuestionId?.let { "Previous" } ?: "Back", when {
onWrittenTextChanged = viewModel::updateWrittenText, state.isLoading -> LoadingState(message = "Opening thread")
onOptionToggled = viewModel::toggleOption, state.error != null -> ErrorState(
onScaleChanged = viewModel::updateScale, title = "Thread unavailable",
onSubmit = viewModel::submitAnswer, message = state.error ?: "Something went wrong."
canSubmit = viewModel.canSubmit() )
) 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 @Composable
fun QuestionThreadScreenPreview() { private fun InputPhase(
LocalQuestionContent( question: Question,
state = LocalQuestionUiState( state: QuestionThreadUiState,
isLoading = false, viewModel: QuestionThreadViewModel
question = Question( ) {
id = "demo", Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
text = "What is one conversation you want us to handle more gently?", Text(
category = "communication", text = "Answer privately. Your partner won't see this until they answer too.",
depthLevel = 3, style = MaterialTheme.typography.bodyMedium,
type = "written" color = MaterialTheme.colorScheme.onSurfaceVariant
) )
), QuestionAnswerInput(
title = "Answer with care", question = question,
subtitle = "Save your answer privately first.", pendingWrittenText = state.pendingWrittenText,
primaryRouteLabel = "Saved answers", pendingSelectedOptionIds = state.pendingSelectedOptionIds,
onPrimaryRoute = {}, pendingScaleValue = state.pendingScaleValue,
onSecondaryRoute = {}, onWrittenTextChanged = viewModel::updateWrittenText,
secondaryRouteLabel = "Back", onOptionToggled = viewModel::toggleOption,
onWrittenTextChanged = {}, onScaleChanged = viewModel::updateScale,
onOptionToggled = {}, onSubmit = viewModel::submitAnswer,
onScaleChanged = {}, canSubmit = viewModel.canSubmit(),
onSubmit = {}, isSubmitting = state.isSubmitting
canSubmit = false )
) }
}
@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."
}
} }