fix: add answer options to wheel session screen so partners can answer questions
This commit is contained in:
parent
7fb913db99
commit
c6df885e1e
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue