refactor: Improve QuizScreen layout and choice presentation

This commit refactors the `QuizScreen` to use a `Column` instead of a `LazyColumn` for its main layout, improving the positioning and sizing of elements. It also introduces an `EvenGrid` composable for displaying choices, ensuring they are evenly distributed.

Key changes:

- **QuizScreen.kt:**
    - Changed the main layout from `LazyColumn` to `Column`.
    - Toolbar, QuestionContent, Choices, Timer, and ContinueButton are now direct children of the `Column`.
    - `QuestionContent` now uses `fillMaxHeight(0.5f)` to take up half the available height.
    - `Choices` composable now uses `weight(1f)` to fill remaining space.
    - Removed `animateContentSize` and `animateItem` modifiers.
    - Refactored how individual UI sections (toolbar, question, choices, timer, continue button) are structured within the main `Column`.
- **components/EvenGrid.kt:**
    - Added a new reusable `EvenGrid` composable that arranges items into a grid with a specified number of columns, ensuring even distribution.
- **components/Choices.kt:**
    - Replaced `FlowRow` with the new `EvenGrid` composable to display choices.
    - `ChoiceItem` now uses `fillMaxHeight()` within the `EvenGrid` cell.
    - Removed explicit height setting for `ChoiceItem`'s `Box`.
- **components/QuestionContent.kt:**
    - `AsyncImage` within `QuestionContent` now uses `weight(1f)` instead of `heightIn(min = 200.dp)`.
- **components/TimerBar.kt:**
    - Removed `coerceIn(0f, 1f)` for `progress` in `fillMaxWidth` as progress should already be within this range.
- **QuizScreenViewModel.kt & domain/models/Question.kt:**
    - Made `Question.choices` non-nullable (`List<Choice>`) and updated the mapper and ViewModel to reflect this. This simplifies null checks for `currentQuestion.choices`.
    - Accessing `quizState.quiz.questions[currentQuestionIndex]` directly instead of `getOrNull` as the index should always be valid.
- **data/mappers/QuizMapper.kt:**
    - Updated `QuestionDto.toDomain()` to return `orEmpty()` for choices, ensuring a non-null list.
This commit is contained in:
2025-09-05 00:06:42 +02:00
parent 21ba338d38
commit 1b57800641
8 changed files with 114 additions and 106 deletions

View File

@@ -153,7 +153,7 @@ private fun QuestionDto.toDomain(): Question = Question(
time = time?.milliseconds, time = time?.milliseconds,
points = points, points = points,
pointsMultiplier = pointsMultiplier, pointsMultiplier = pointsMultiplier,
choices = choices?.map { it.toDomain() }, choices = choices?.map { it.toDomain() }.orEmpty(),
layout = layout, layout = layout,
image = image, image = image,
imageMetadata = imageMetadata?.toDomain(), imageMetadata = imageMetadata?.toDomain(),

View File

@@ -10,7 +10,7 @@ data class Question(
val time: Duration?, val time: Duration?,
val points: Boolean? = null, val points: Boolean? = null,
val pointsMultiplier: Int?, val pointsMultiplier: Int?,
val choices: List<Choice>?, val choices: List<Choice>,
val layout: String? = null, val layout: String? = null,
val image: String? = null, val image: String? = null,
val imageMetadata: ImageMetadata?, val imageMetadata: ImageMetadata?,

View File

@@ -1,13 +1,13 @@
package dev.adriankuta.kahootquiz.ui.quiz package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -85,28 +85,10 @@ private fun QuizScreenSuccess(
onContinue: () -> Unit, onContinue: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth(),
.animateContentSize(),
) { ) {
toolbar(uiState)
questionContent(uiState)
choices(uiState, onSelect)
// Timer below choices
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) {
timer(uiState)
} else {
continueButton(uiState, onContinue)
}
}
}
private fun LazyListScope.toolbar(
uiState: ScreenUiState.Success,
modifier: Modifier = Modifier,
) {
item(key = "toolbar") {
Box( Box(
modifier = modifier modifier = modifier
.height(72.dp), .height(72.dp),
@@ -124,25 +106,56 @@ private fun LazyListScope.toolbar(
) )
} }
} }
}
}
private fun LazyListScope.questionContent( QuestionContent(
uiState: ScreenUiState.Success, question = uiState.currentQuestion,
) { modifier = Modifier
if (uiState.currentQuestion != null) { .padding(horizontal = 8.dp)
item(key = "question_${uiState.currentQuestionIndex}") { .fillMaxHeight(0.5f),
QuestionContent( )
question = uiState.currentQuestion, Spacer(Modifier.height(8.dp))
modifier = Modifier
.padding(horizontal = 8.dp)
.animateItem(), Choices(
choices = uiState.currentQuestion.choices,
selectedChoiceIndex = uiState.selectedChoiceIndex,
onSelect = onSelect,
modifier = Modifier
.padding(8.dp)
.weight(1f),
)
// Timer below choices
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) {
TimerBar(
totalSeconds = uiState.timerState.totalTimeSeconds,
remainingSeconds = uiState.timerState.remainingTimeSeconds,
modifier = Modifier.padding(8.dp),
) )
Spacer(Modifier.height(8.dp)) } else {
Box(
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = onContinue,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = Grey,
contentColor = Color.Black,
),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.align(Alignment.Center),
) {
Text(
text = stringResource(R.string.continue_text),
)
}
}
} }
} }
} }
private fun LazyListScope.timer(uiState: ScreenUiState.Success) { private fun LazyListScope.timer(uiState: ScreenUiState.Success) {
item(key = "timer_${uiState.currentQuestionIndex}") { item(key = "timer_${uiState.currentQuestionIndex}") {
TimerBar( TimerBar(
@@ -153,49 +166,6 @@ private fun LazyListScope.timer(uiState: ScreenUiState.Success) {
} }
} }
private fun LazyListScope.continueButton(
uiState: ScreenUiState.Success,
onContinue: () -> Unit,
) {
item(key = "continue_${uiState.currentQuestionIndex}") {
Box(
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = onContinue,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = Grey,
contentColor = Color.Black,
),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.align(Alignment.Center),
) {
Text(
text = stringResource(R.string.continue_text),
)
}
}
}
}
private fun LazyListScope.choices(
uiState: ScreenUiState.Success,
onSelect: (Int) -> Unit,
) {
uiState.currentQuestion?.choices?.let { choicesList ->
if (choicesList.isNotEmpty()) {
item(key = "choices_${uiState.currentQuestionIndex}") {
Choices(
choices = choicesList,
selectedChoiceIndex = uiState.selectedChoiceIndex,
onSelect = onSelect,
modifier = Modifier.padding(8.dp),
)
}
}
}
}
@Preview @Preview
@Composable @Composable
private fun QuizScreenPreview() { private fun QuizScreenPreview() {

View File

@@ -141,9 +141,9 @@ private fun screenUiState(
when (quizState) { when (quizState) {
QuizUiState.Loading -> ScreenUiState.Loading QuizUiState.Loading -> ScreenUiState.Loading
is QuizUiState.Success -> { is QuizUiState.Success -> {
val currentQuestion = quizState.quiz.questions.getOrNull(currentQuestionIndex) val currentQuestion = quizState.quiz.questions[currentQuestionIndex]
val isAnswerCorrect = selectedChoiceIndex?.let { idx -> val isAnswerCorrect = selectedChoiceIndex?.let { idx ->
currentQuestion?.choices?.getOrNull(idx)?.correct == true currentQuestion.choices?.getOrNull(idx)?.correct == true
} }
ScreenUiState.Success( ScreenUiState.Success(
@@ -153,7 +153,7 @@ private fun screenUiState(
totalQuestions = quizState.quiz.questions.size, totalQuestions = quizState.quiz.questions.size,
timerState = TimerState( timerState = TimerState(
remainingTimeSeconds = remainingTimeSeconds, remainingTimeSeconds = remainingTimeSeconds,
totalTimeSeconds = currentQuestion?.time?.inWholeSeconds?.toInt() ?: 0, totalTimeSeconds = currentQuestion.time?.inWholeSeconds?.toInt() ?: 0,
), ),
isAnswerCorrect = isAnswerCorrect, isAnswerCorrect = isAnswerCorrect,
) )
@@ -171,7 +171,7 @@ sealed interface QuizUiState {
sealed interface ScreenUiState { sealed interface ScreenUiState {
data object Loading : ScreenUiState data object Loading : ScreenUiState
data class Success( data class Success(
val currentQuestion: Question? = null, val currentQuestion: Question,
val selectedChoiceIndex: Int? = null, val selectedChoiceIndex: Int? = null,
val currentQuestionIndex: Int = 0, val currentQuestionIndex: Int = 0,
val totalQuestions: Int = 0, val totalQuestions: Int = 0,

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalLayoutApi::class)
package dev.adriankuta.kahootquiz.ui.quiz.components package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -5,8 +7,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -37,21 +39,22 @@ fun Choices(
selectedChoiceIndex: Int?, selectedChoiceIndex: Int?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FlowRow( EvenGrid(
maxItemsInEachRow = 2, items = choices,
columns = 2,
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { horizontalArrangement = Arrangement.spacedBy(8.dp),
choices.forEachIndexed { index, choice -> ) { choice, index ->
ChoiceItem( ChoiceItem(
choice = choice, choice = choice,
index = index, index = index,
selectedChoiceIndex = selectedChoiceIndex, selectedChoiceIndex = selectedChoiceIndex,
onClick = { onSelect(index) }, onClick = { onSelect(index) },
modifier = Modifier.weight(1f), modifier = Modifier
) .weight(1f)
} .fillMaxHeight(),
)
} }
} }
@@ -104,7 +107,6 @@ private fun ChoiceItemDefault(
Box( Box(
modifier = modifier modifier = modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp)) .background(backgroundColor, shape = RoundedCornerShape(4.dp))
.height(100.dp)
.clickable( .clickable(
onClick = onClick, onClick = onClick,
), ),
@@ -152,8 +154,7 @@ private fun ChoiceItemRevealed(
Box( Box(
modifier = modifier modifier = modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp)) .background(backgroundColor, shape = RoundedCornerShape(4.dp)),
.height(100.dp),
) { ) {
Image( Image(
painter = painterResource(icon), painter = painterResource(icon),

View File

@@ -0,0 +1,39 @@
package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun <T> EvenGrid(
items: List<T>,
columns: Int,
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
content: @Composable RowScope.(item: T, index: Int) -> Unit,
) {
val rows = (items.size + columns - 1) / columns // total rows needed
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
repeat(rows) { rowIndex ->
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = horizontalArrangement,
) {
repeat(columns) { columnIndex ->
val itemIndex = rowIndex * columns + columnIndex
if (itemIndex < items.size) {
content(items[itemIndex], itemIndex)
}
}
}
}
}
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,8 +33,7 @@ fun QuestionContent(
contentDescription = question.imageMetadata?.altText, contentDescription = question.imageMetadata?.altText,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.heightIn(min = 200.dp)
.clip(shape = RoundedCornerShape(4.dp)), .clip(shape = RoundedCornerShape(4.dp)),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@@ -33,7 +33,7 @@ fun TimerBar(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth(progress.coerceIn(0f, 1f)) .fillMaxWidth(progress)
.background( .background(
color = Purple, color = Purple,
shape = RoundedCornerShape(percent = 50), shape = RoundedCornerShape(percent = 50),