refactor(android): update question thread and answer mapping patterns
This commit is contained in:
parent
eebdee6120
commit
5a2bdf7b0f
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue