Closer/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt

939 lines
36 KiB
Kotlin

package app.closer.ui.howwell
import android.util.Log
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.AnswerConfig
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository
import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
// ── Domain ────────────────────────────────────────────────────────────────────
data class HowWellAnswer(
val selectedOptionId: String? = null,
val scaleValue: Int? = null
) {
val isEmpty get() = selectedOptionId == null && scaleValue == null
}
fun HowWellAnswer.isMatch(other: HowWellAnswer): Boolean = when {
selectedOptionId != null -> selectedOptionId == other.selectedOptionId
scaleValue != null && other.scaleValue != null -> scaleValue == other.scaleValue
else -> false
}
fun HowWellAnswer.isClose(other: HowWellAnswer): Boolean =
scaleValue != null && other.scaleValue != null &&
!isMatch(other) && abs(scaleValue - other.scaleValue) == 1
fun HowWellAnswer.displayText(config: AnswerConfig?): String = when {
selectedOptionId != null -> when (config) {
is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: selectedOptionId
is ThisOrThatAnswerConfigImpl -> when (selectedOptionId) {
config.config.optionA.id -> config.config.optionA.text
config.config.optionB.id -> config.config.optionB.text
else -> selectedOptionId
}
else -> selectedOptionId
}
scaleValue != null -> "$scaleValue"
else -> ""
}
data class HowWellResult(
val question: Question,
val playerAAnswer: HowWellAnswer,
val playerBAnswer: HowWellAnswer,
val isMatch: Boolean,
val isClose: Boolean
)
enum class HowWellPhase {
LOADING, PLAYER_A_INTRO, PLAYER_A_TURN, HANDOFF,
PLAYER_B_TURN, REVEALING, COMPLETE
}
data class HowWellUiState(
val phase: HowWellPhase = HowWellPhase.LOADING,
val questions: List<Question> = emptyList(),
val currentIndex: Int = 0,
val playerAAnswers: List<HowWellAnswer> = emptyList(),
val results: List<HowWellResult> = emptyList(),
val selectedOptionId: String? = null,
val selectedScale: Int? = null,
val score: Int = 0,
val error: String? = null
)
// ── ViewModel ─────────────────────────────────────────────────────────────────
@HiltViewModel
class HowWellViewModel @Inject constructor(
private val repository: QuestionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(HowWellUiState())
val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow()
init { load() }
private fun load() {
viewModelScope.launch {
val questions = runCatching {
repository.getQuestionsForPrediction().shuffled().take(SESSION_SIZE)
}
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() }
_uiState.update {
it.copy(
phase = if (questions.isEmpty()) HowWellPhase.LOADING else HowWellPhase.PLAYER_A_INTRO,
questions = questions,
error = if (questions.isEmpty()) "No questions available." else null
)
}
}
}
fun selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) }
fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) }
fun startPlayerA() = _uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) }
fun confirmAnswer() {
val s = _uiState.value
val answer = HowWellAnswer(s.selectedOptionId, s.selectedScale)
if (answer.isEmpty) return
val newAnswers = s.playerAAnswers + answer
val next = s.currentIndex + 1
_uiState.update {
it.copy(
playerAAnswers = newAnswers,
selectedOptionId = null,
selectedScale = null,
currentIndex = if (next < it.questions.size) next else it.currentIndex,
phase = if (next >= it.questions.size) HowWellPhase.HANDOFF else HowWellPhase.PLAYER_A_TURN
)
}
}
fun readyForPlayerB() = _uiState.update {
it.copy(phase = HowWellPhase.PLAYER_B_TURN, currentIndex = 0, selectedOptionId = null, selectedScale = null)
}
fun confirmPrediction() {
val s = _uiState.value
val prediction = HowWellAnswer(s.selectedOptionId, s.selectedScale)
if (prediction.isEmpty) return
val actual = s.playerAAnswers.getOrNull(s.currentIndex) ?: return
val question = s.questions.getOrNull(s.currentIndex) ?: return
val match = prediction.isMatch(actual)
val close = prediction.isClose(actual)
_uiState.update {
it.copy(
results = it.results + HowWellResult(question, actual, prediction, match, close),
selectedOptionId = null,
selectedScale = null,
score = if (match) it.score + 1 else it.score,
phase = HowWellPhase.REVEALING
)
}
}
fun nextQuestion() {
val next = _uiState.value.currentIndex + 1
val total = _uiState.value.questions.size
_uiState.update {
it.copy(
currentIndex = if (next < total) next else it.currentIndex,
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN
)
}
}
fun restart() {
_uiState.value = HowWellUiState()
load()
}
companion object {
const val SESSION_SIZE = 10
private const val TAG = "HowWellViewModel"
}
}
// ── Root screen ───────────────────────────────────────────────────────────────
@Composable
fun HowWellScreen(
onNavigate: (String) -> Unit = {},
viewModel: HowWellViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(closerBackgroundBrush())
) {
when (state.phase) {
HowWellPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
)
HowWellPhase.PLAYER_A_INTRO -> PlayerIntroScreen(
playerNumber = 1,
total = state.questions.size,
onReady = viewModel::startPlayerA
)
HowWellPhase.PLAYER_A_TURN -> {
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
AnswerScreen(
question = q,
index = state.currentIndex,
total = state.questions.size,
isPlayerB = false,
selectedOptionId = state.selectedOptionId,
selectedScale = state.selectedScale,
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmAnswer,
onQuit = { onNavigate(AppRoute.PLAY) }
)
}
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB)
HowWellPhase.PLAYER_B_TURN -> {
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
AnswerScreen(
question = q,
index = state.currentIndex,
total = state.questions.size,
isPlayerB = true,
selectedOptionId = state.selectedOptionId,
selectedScale = state.selectedScale,
onSelectOption = viewModel::selectOption,
onSelectScale = viewModel::selectScale,
onConfirm = viewModel::confirmPrediction,
onQuit = { onNavigate(AppRoute.PLAY) }
)
}
HowWellPhase.REVEALING -> {
val result = state.results.lastOrNull() ?: return@Box
RevealScreen(
result = result,
questionNumber = state.currentIndex + 1,
total = state.questions.size,
score = state.score,
onNext = viewModel::nextQuestion
)
}
HowWellPhase.COMPLETE -> CompleteScreen(
score = state.score,
total = state.questions.size,
results = state.results,
onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) }
)
}
}
}
// ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusGlyph(
icon = if (playerNumber == 1) Icons.Filled.Person else Icons.Filled.Psychology,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
Spacer(Modifier.height(20.dp))
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Player $playerNumber",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(16.dp))
Text(
text = if (playerNumber == 1)
"Answer $total questions honestly.\nYour partner will try to predict what you said."
else
"For each question, guess what your partner answered.\nNo peeking!",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.headlineSmall.lineHeight
)
if (playerNumber == 1) {
Spacer(Modifier.height(10.dp))
Text(
text = "Ask your partner to look away.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("I'm ready", color = Color.White) }
}
}
@Composable
private fun HandoffScreen(onReady: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusGlyph(
icon = Icons.Filled.Sync,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist
)
Spacer(Modifier.height(20.dp))
Text(
text = "Pass the phone!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
text = "Player 1 is done. Hand the phone to Player 2 — keep your answers secret!",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("I'm Player 2, let's go!", color = Color.White) }
}
}
@Composable
private fun AnswerScreen(
question: Question,
index: Int,
total: Int,
isPlayerB: Boolean,
selectedOptionId: String?,
selectedScale: Int?,
onSelectOption: (String) -> Unit,
onSelectScale: (Int) -> Unit,
onConfirm: () -> Unit,
onQuit: () -> Unit
) {
val hasSelection = selectedOptionId != null || selectedScale != null
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Player ${if (isPlayerB) 2 else 1} · ${index + 1} / $total",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
TextButton(onClick = onQuit) {
Text("Quit", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
LinearProgressIndicator(
progress = { index.toFloat() / total },
modifier = Modifier.fillMaxWidth(),
color = CloserPalette.PurpleDeep,
trackColor = CloserPalette.PurpleMist
)
Card(
modifier = Modifier.fillMaxWidth().weight(1f),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(8.dp)
) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (isPlayerB) {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "What did Player 1 say?",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
}
Text(
text = question.text,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center,
maxLines = 5,
overflow = TextOverflow.Ellipsis
)
}
}
}
when (val config = question.answerConfig) {
is ChoiceAnswerConfigImpl -> SingleChoiceInput(
options = config.config.options,
selectedId = selectedOptionId,
onSelect = onSelectOption
)
is ThisOrThatAnswerConfigImpl -> BinaryChoiceInput(
optionA = config.config.optionA,
optionB = config.config.optionB,
selectedId = selectedOptionId,
onSelect = onSelectOption
)
is ScaleAnswerConfigImpl -> ScaleInput(
min = config.config.minScale,
max = config.config.maxScale,
minLabel = config.config.minLabel,
maxLabel = config.config.maxLabel,
selected = selectedScale,
onSelect = onSelectScale
)
else -> {}
}
Button(
onClick = onConfirm,
enabled = hasSelection,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
Text(
text = if (index + 1 >= total && !isPlayerB) "Done →" else "Confirm →",
color = Color.White
)
}
}
}
@Composable
private fun RevealScreen(
result: HowWellResult,
questionNumber: Int,
total: Int,
score: Int,
onNext: () -> Unit
) {
val matchColor = Color(0xFF2E7D32)
val closeColor = Color(0xFFF57F17)
val missColor = Color(0xFFC62828)
val accentColor = when {
result.isMatch -> matchColor
result.isClose -> closeColor
else -> missColor
}
val bgColor = when {
result.isMatch -> Color(0xFFE8F5E9)
result.isClose -> Color(0xFFFFF8E1)
else -> Color(0xFFFCE4EC)
}
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$questionNumber / $total",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
Text(
text = "Score: $score / $questionNumber",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
fontWeight = FontWeight.SemiBold
)
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = bgColor),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
ResultGlyph(
isPositive = result.isMatch,
isClose = result.isClose,
size = 38.dp
)
Text(
text = if (result.isMatch) "Match!" else if (result.isClose) "So close!" else "Not quite",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = accentColor
)
}
}
Text(
text = result.question.text,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AnswerRevealCard(
label = "Player 1 said",
text = result.playerAAnswer.displayText(result.question.answerConfig),
isMatch = result.isMatch,
modifier = Modifier.weight(1f)
)
AnswerRevealCard(
label = "Player 2 guessed",
text = result.playerBAnswer.displayText(result.question.answerConfig),
isMatch = result.isMatch,
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.weight(1f))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
Text(
text = if (questionNumber >= total) "See results" else "Next →",
color = Color.White
)
}
}
}
@Composable
private fun AnswerRevealCard(
label: String,
text: String,
isMatch: Boolean,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.heightIn(min = 90.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isMatch) Color(0xFF2E7D32) else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = if (isMatch) Color(0xFF1B5E20) else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun CompleteScreen(
score: Int,
total: Int,
results: List<HowWellResult>,
onPlayAgain: () -> Unit,
onHome: () -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
StatusGlyph(
icon = Icons.Filled.Timeline,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleMist,
size = 82.dp,
iconSize = 40.dp
)
Text(
text = "$score / $total",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
color = CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
Text(
text = scoreLabel(score, total),
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
)
}
}
item {
Text(
text = "Breakdown",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground
)
}
items(results) { result -> BreakdownRow(result) }
item {
Column(
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
}
}
}
}
@Composable
private fun BreakdownRow(result: HowWellResult) {
val matchColor = Color(0xFF2E7D32)
val closeColor = Color(0xFFF57F17)
val missColor = Color(0xFFC62828)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(2.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
ResultGlyph(
isPositive = result.isMatch,
isClose = result.isClose,
size = 28.dp
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = result.question.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val config = result.question.answerConfig
if (result.isMatch) {
Text(
text = result.playerAAnswer.displayText(config),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = matchColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = result.playerAAnswer.displayText(config),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = if (result.isClose) closeColor else missColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
Text(
text = "${result.playerBAnswer.displayText(config)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
}
}
}
}
}
}
// ── Answer input widgets ──────────────────────────────────────────────────────
@Composable
private fun SingleChoiceInput(
options: List<ChoiceOption>,
selectedId: String?,
onSelect: (String) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
val selected = selectedId == option.id
Card(
onClick = { onSelect(option.id) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(if (selected) 6.dp else 2.dp)
) {
Text(
text = option.text,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun BinaryChoiceInput(
optionA: ChoiceOption,
optionB: ChoiceOption,
selectedId: String?,
onSelect: (String) -> Unit
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
listOf(optionA to "A", optionB to "B").forEach { (option, label) ->
val selected = selectedId == option.id
Card(
onClick = { onSelect(option.id) },
modifier = Modifier.weight(1f).heightIn(min = 80.dp),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(if (selected) 6.dp else 2.dp)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (selected) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = option.text,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
private fun ScaleInput(
min: Int,
max: Int,
minLabel: String,
maxLabel: String,
selected: Int?,
onSelect: (Int) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
(min..max).forEach { value ->
val isSelected = selected == value
Surface(
onClick = { onSelect(value) },
shape = CircleShape,
color = if (isSelected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface,
modifier = Modifier.size(52.dp),
shadowElevation = if (isSelected) 6.dp else 2.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "$value",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface
)
}
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(minLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(maxLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun scoreLabel(score: Int, total: Int): String = when {
score == total -> "Perfect read"
score >= total * 0.8 -> "You really know each other"
score >= total * 0.6 -> "Pretty good!"
score >= total * 0.4 -> "Getting there!"
else -> "Room to grow"
}