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

@@ -1,13 +1,13 @@
package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
@@ -85,28 +85,10 @@ private fun QuizScreenSuccess(
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
Column(
modifier = modifier
.fillMaxWidth()
.animateContentSize(),
.fillMaxWidth(),
) {
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(
modifier = modifier
.height(72.dp),
@@ -124,25 +106,56 @@ private fun LazyListScope.toolbar(
)
}
}
}
}
private fun LazyListScope.questionContent(
uiState: ScreenUiState.Success,
) {
if (uiState.currentQuestion != null) {
item(key = "question_${uiState.currentQuestionIndex}") {
QuestionContent(
question = uiState.currentQuestion,
modifier = Modifier
.padding(horizontal = 8.dp)
.animateItem(),
QuestionContent(
question = uiState.currentQuestion,
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight(0.5f),
)
Spacer(Modifier.height(8.dp))
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) {
item(key = "timer_${uiState.currentQuestionIndex}") {
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
@Composable
private fun QuizScreenPreview() {

View File

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

View File

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

View File

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