mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-14 17:24:21 +02:00
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:
@@ -153,7 +153,7 @@ private fun QuestionDto.toDomain(): Question = Question(
|
||||
time = time?.milliseconds,
|
||||
points = points,
|
||||
pointsMultiplier = pointsMultiplier,
|
||||
choices = choices?.map { it.toDomain() },
|
||||
choices = choices?.map { it.toDomain() }.orEmpty(),
|
||||
layout = layout,
|
||||
image = image,
|
||||
imageMetadata = imageMetadata?.toDomain(),
|
||||
|
@@ -10,7 +10,7 @@ data class Question(
|
||||
val time: Duration?,
|
||||
val points: Boolean? = null,
|
||||
val pointsMultiplier: Int?,
|
||||
val choices: List<Choice>?,
|
||||
val choices: List<Choice>,
|
||||
val layout: String? = null,
|
||||
val image: String? = null,
|
||||
val imageMetadata: ImageMetadata?,
|
||||
|
@@ -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() {
|
||||
|
@@ -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,
|
||||
|
@@ -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),
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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))
|
||||
|
@@ -33,7 +33,7 @@ fun TimerBar(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(progress.coerceIn(0f, 1f))
|
||||
.fillMaxWidth(progress)
|
||||
.background(
|
||||
color = Purple,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
|
Reference in New Issue
Block a user