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.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question 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 @Composable
fun WheelSessionScreen( fun WheelSessionScreen(
@ -58,6 +76,9 @@ fun WheelSessionScreen(
WheelSessionContent( WheelSessionContent(
state = state, state = state,
onSelectOption = viewModel::selectOption,
onScaleChanged = viewModel::onScaleChanged,
onWrittenTextChanged = viewModel::onWrittenTextChanged,
onNext = viewModel::next, onNext = viewModel::next,
onSkip = viewModel::skip, onSkip = viewModel::skip,
onEnd = viewModel::endEarly onEnd = viewModel::endEarly
@ -67,6 +88,9 @@ fun WheelSessionScreen(
@Composable @Composable
private fun WheelSessionContent( private fun WheelSessionContent(
state: WheelSessionUiState, state: WheelSessionUiState,
onSelectOption: (String) -> Unit,
onScaleChanged: (Int) -> Unit,
onWrittenTextChanged: (String) -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onSkip: () -> Unit, onSkip: () -> Unit,
onEnd: () -> Unit onEnd: () -> Unit
@ -74,9 +98,7 @@ private fun WheelSessionContent(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(closerBackgroundBrush())
closerBackgroundBrush()
)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -84,7 +106,7 @@ private fun WheelSessionContent(
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 24.dp), .padding(horizontal = 24.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
if (state.isEmpty) { if (state.isEmpty) {
EmptySessionCard() EmptySessionCard()
@ -95,6 +117,7 @@ private fun WheelSessionContent(
val current = state.currentIndex val current = state.currentIndex
val progress = if (total > 0) (current.toFloat() / total) else 0f val progress = if (total > 0) (current.toFloat() / total) else 0f
// Header row
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@ -125,6 +148,7 @@ private fun WheelSessionContent(
) )
} }
// Progress bar
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress }, progress = { progress },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -132,6 +156,7 @@ private fun WheelSessionContent(
trackColor = Color(0xFFF4E8FF) trackColor = Color(0xFFF4E8FF)
) )
// Question card
val question = state.questions.getOrNull(current) val question = state.questions.getOrNull(current)
Card( Card(
@ -142,24 +167,40 @@ private fun WheelSessionContent(
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)), colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp) elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(28.dp), .verticalScroll(rememberScrollState())
contentAlignment = Alignment.Center .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
// Question text
Text( Text(
text = question?.text ?: "", text = question?.text ?: "",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
maxLines = 8, 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( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp) 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 @Composable
private fun EmptySessionCard() { private fun EmptySessionCard() {
Card( Card(
@ -246,6 +497,9 @@ fun WheelSessionScreenPreview() {
currentIndex = 0, currentIndex = 0,
categoryName = "Trust" categoryName = "Trust"
), ),
onSelectOption = {},
onScaleChanged = {},
onWrittenTextChanged = {},
onNext = {}, onNext = {},
onSkip = {}, onSkip = {},
onEnd = {} onEnd = {}

View File

@ -14,9 +14,13 @@ data class WheelSessionUiState(
val questions: List<Question> = emptyList(), val questions: List<Question> = emptyList(),
val currentIndex: Int = 0, val currentIndex: Int = 0,
val skippedCount: Int = 0, val skippedCount: Int = 0,
val answeredCount: Int = 0,
val categoryName: String = "", val categoryName: String = "",
val navigateTo: String? = null, 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 @HiltViewModel
@ -32,22 +36,67 @@ class WheelSessionViewModel @Inject constructor(
if (session == null) { if (session == null) {
_uiState.update { it.copy(isEmpty = true) } _uiState.update { it.copy(isEmpty = true) }
} else { } else {
val firstQuestion = session.questions.firstOrNull()
_uiState.update { _uiState.update {
it.copy( it.copy(
questions = session.questions, 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() { fun next() {
val state = _uiState.value val state = _uiState.value
val hasSelection = hasValidSelection(state)
val nextIndex = state.currentIndex + 1 val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) { 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 { } 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 state = _uiState.value
val nextIndex = state.currentIndex + 1 val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) { if (nextIndex >= state.questions.size) {
finishSession() sessionStore.lastAnswered = state.answeredCount
sessionStore.lastTotal = state.questions.size
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) }
} else { } 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() { fun endEarly() {
finishSession()
}
private fun finishSession() {
val state = _uiState.value val state = _uiState.value
sessionStore.lastAnswered = (state.currentIndex + 1).coerceAtMost(state.questions.size) sessionStore.lastAnswered = state.answeredCount
sessionStore.lastTotal = state.questions.size sessionStore.lastTotal = state.questions.size
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("session")) } _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() { fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }