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

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

View File

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

View File

@ -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<String> = 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<LocalQuestionUiState> = _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.
*

View File

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

View File

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

View File

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