mirror of
				https://github.com/AdrianKuta/KahootQuiz.git
				synced 2025-10-31 00:43:40 +01: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:
		| @@ -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