From c6df885e1e90048371ec804481cb5ed1f801a0bb Mon Sep 17 00:00:00 2001 From: null Date: Thu, 18 Jun 2026 01:09:08 -0500 Subject: [PATCH] fix: add answer options to wheel session screen so partners can answer questions --- .../app/closer/ui/wheel/WheelSessionScreen.kt | 272 +++++++++++++++++- .../closer/ui/wheel/WheelSessionViewModel.kt | 89 +++++- 2 files changed, 341 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt index 7c5a0280..32b23fdf 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt @@ -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, + 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 = {} diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index b2d02990..8510d1f8 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -14,9 +14,13 @@ data class WheelSessionUiState( val questions: List = 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 = 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) } }