fix: add answer options to wheel session screen so partners can answer questions

This commit is contained in:
null 2026-06-18 01:09:08 -05:00
parent 7fb913db99
commit c6df885e1e
2 changed files with 341 additions and 20 deletions

View File

@ -2,25 +2,39 @@ package app.closer.ui.wheel
import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.BorderStroke
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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -39,7 +53,11 @@ 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 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.model.WrittenAnswerConfigImpl
@Composable
fun WheelSessionScreen(
@ -58,6 +76,9 @@ fun WheelSessionScreen(
WheelSessionContent(
state = state,
onSelectOption = viewModel::selectOption,
onScaleChanged = viewModel::onScaleChanged,
onWrittenTextChanged = viewModel::onWrittenTextChanged,
onNext = viewModel::next,
onSkip = viewModel::skip,
onEnd = viewModel::endEarly
@ -67,6 +88,9 @@ fun WheelSessionScreen(
@Composable
private fun WheelSessionContent(
state: WheelSessionUiState,
onSelectOption: (String) -> Unit,
onScaleChanged: (Int) -> Unit,
onWrittenTextChanged: (String) -> Unit,
onNext: () -> Unit,
onSkip: () -> Unit,
onEnd: () -> Unit
@ -74,9 +98,7 @@ private fun WheelSessionContent(
Box(
modifier = Modifier
.fillMaxSize()
.background(
closerBackgroundBrush()
)
.background(closerBackgroundBrush())
) {
Column(
modifier = Modifier
@ -84,7 +106,7 @@ private fun WheelSessionContent(
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (state.isEmpty) {
EmptySessionCard()
@ -95,6 +117,7 @@ private fun WheelSessionContent(
val current = state.currentIndex
val progress = if (total > 0) (current.toFloat() / total) else 0f
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -125,6 +148,7 @@ private fun WheelSessionContent(
)
}
// Progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
@ -132,6 +156,7 @@ private fun WheelSessionContent(
trackColor = Color(0xFFF4E8FF)
)
// Question card
val question = state.questions.getOrNull(current)
Card(
@ -142,24 +167,40 @@ private fun WheelSessionContent(
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(28.dp),
contentAlignment = Alignment.Center
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Question text
Text(
text = question?.text ?: "",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
maxLines = 8,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
// Answer options
if (question != null) {
AnswerOptions(
question = question,
selectedOptionIds = state.selectedOptionIds,
selectedScaleValue = state.selectedScaleValue,
writtenText = state.writtenText,
onSelectOption = onSelectOption,
onScaleChanged = onScaleChanged,
onWrittenTextChanged = onWrittenTextChanged
)
}
}
}
// Action buttons
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
@ -204,6 +245,216 @@ private fun WheelSessionContent(
}
}
@Composable
private fun AnswerOptions(
question: Question,
selectedOptionIds: List<String>,
selectedScaleValue: Int,
writtenText: String,
onSelectOption: (String) -> Unit,
onScaleChanged: (Int) -> Unit,
onWrittenTextChanged: (String) -> Unit
) {
when (question.type) {
"written" -> {
val config = question.answerConfig as? WrittenAnswerConfigImpl
OutlinedTextField(
value = writtenText,
onValueChange = onWrittenTextChanged,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
placeholder = {
Text(
text = config?.config?.placeholder ?: "Write your answer…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
},
singleLine = false,
minLines = 3,
maxLines = 6,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF56306F),
unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface
),
textStyle = MaterialTheme.typography.bodyLarge
)
}
"single_choice" -> {
val config = question.answerConfig as? ChoiceAnswerConfigImpl
val options = config?.config?.options ?: return
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
val isSelected = option.id in selectedOptionIds
OutlinedButton(
onClick = { onSelectOption(option.id) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) Color(0xFF56306F)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isSelected) Color(0xFFF0EDF9)
else MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
RadioButton(
selected = isSelected,
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = Color(0xFF56306F)
)
)
Text(
text = option.text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
"multi_choice" -> {
val config = question.answerConfig as? ChoiceAnswerConfigImpl
val options = config?.config?.options ?: return
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
val isChecked = option.id in selectedOptionIds
OutlinedButton(
onClick = { onSelectOption(option.id) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(
width = if (isChecked) 2.dp else 1.dp,
color = if (isChecked) Color(0xFF56306F)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isChecked) Color(0xFFF0EDF9)
else MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = isChecked,
onCheckedChange = null,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF56306F)
)
)
Text(
text = option.text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
"scale" -> {
val config = question.answerConfig as? ScaleAnswerConfigImpl
val cfg = config?.config ?: return
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "$selectedScaleValue",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
color = Color(0xFF56306F)
)
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = selectedScaleValue.toFloat(),
onValueChange = { onScaleChanged(it.toInt()) },
valueRange = cfg.minScale.toFloat()..cfg.maxScale.toFloat(),
steps = (cfg.maxScale - cfg.minScale - 1).coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = Color(0xFF56306F),
activeTrackColor = Color(0xFF56306F),
inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = cfg.minLabel.ifBlank { "${cfg.minScale}" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = cfg.maxLabel.ifBlank { "${cfg.maxScale}" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
"this_or_that" -> {
val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
val cfg = config?.config ?: return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
listOf(cfg.optionA, cfg.optionB).forEach { option ->
val isSelected = option.id in selectedOptionIds
Button(
onClick = { onSelectOption(option.id) },
modifier = Modifier
.weight(1f)
.height(96.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isSelected) Color(0xFF56306F)
else MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (isSelected) Color.White
else MaterialTheme.colorScheme.onSurfaceVariant
),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = if (isSelected) 4.dp else 0.dp
)
) {
Text(
text = option.text,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
@Composable
private fun EmptySessionCard() {
Card(
@ -246,6 +497,9 @@ fun WheelSessionScreenPreview() {
currentIndex = 0,
categoryName = "Trust"
),
onSelectOption = {},
onScaleChanged = {},
onWrittenTextChanged = {},
onNext = {},
onSkip = {},
onEnd = {}

View File

@ -14,9 +14,13 @@ data class WheelSessionUiState(
val questions: List<Question> = emptyList(),
val currentIndex: Int = 0,
val skippedCount: Int = 0,
val answeredCount: Int = 0,
val categoryName: String = "",
val navigateTo: String? = null,
val isEmpty: Boolean = false
val isEmpty: Boolean = false,
val selectedOptionIds: List<String> = emptyList(),
val selectedScaleValue: Int = 3,
val writtenText: String = ""
)
@HiltViewModel
@ -32,22 +36,67 @@ class WheelSessionViewModel @Inject constructor(
if (session == null) {
_uiState.update { it.copy(isEmpty = true) }
} else {
val firstQuestion = session.questions.firstOrNull()
_uiState.update {
it.copy(
questions = session.questions,
categoryName = session.categoryName
categoryName = session.categoryName,
selectedScaleValue = defaultScaleValue(firstQuestion)
)
}
}
}
private fun defaultScaleValue(question: Question?): Int {
val config = question?.answerConfig
if (config is app.closer.domain.model.ScaleAnswerConfigImpl) {
return (config.config.minScale + config.config.maxScale) / 2
}
return 3
}
fun selectOption(optionId: String) {
val question = _uiState.value.questions.getOrNull(_uiState.value.currentIndex) ?: return
if (question.type == "multi_choice") {
_uiState.update {
val current = it.selectedOptionIds.toMutableList()
if (optionId in current) current.remove(optionId) else current.add(optionId)
it.copy(selectedOptionIds = current)
}
} else {
_uiState.update { it.copy(selectedOptionIds = listOf(optionId)) }
}
}
fun onScaleChanged(value: Int) {
_uiState.update { it.copy(selectedScaleValue = value) }
}
fun onWrittenTextChanged(text: String) {
_uiState.update { it.copy(writtenText = text) }
}
fun next() {
val state = _uiState.value
val hasSelection = hasValidSelection(state)
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) {
finishSession()
// 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 {
_uiState.update { it.copy(currentIndex = nextIndex) }
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 = ""
)
}
}
}
@ -55,23 +104,41 @@ class WheelSessionViewModel @Inject constructor(
val state = _uiState.value
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) {
finishSession()
sessionStore.lastAnswered = state.answeredCount
sessionStore.lastTotal = state.questions.size
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
} else {
_uiState.update { it.copy(currentIndex = nextIndex, skippedCount = state.skippedCount + 1) }
val nextQuestion = state.questions.getOrNull(nextIndex)
_uiState.update {
it.copy(
currentIndex = nextIndex,
skippedCount = state.skippedCount + 1,
selectedOptionIds = emptyList(),
selectedScaleValue = defaultScaleValue(nextQuestion),
writtenText = ""
)
}
}
}
fun endEarly() {
finishSession()
}
private fun finishSession() {
val state = _uiState.value
sessionStore.lastAnswered = (state.currentIndex + 1).coerceAtMost(state.questions.size)
sessionStore.lastAnswered = state.answeredCount
sessionStore.lastTotal = state.questions.size
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
}
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()
"scale" -> true // always has a default value
else -> false
}
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}