mirror of
				https://github.com/AdrianKuta/KahootQuiz.git
				synced 2025-10-31 00:43:40 +01:00 
			
		
		
		
	feat: Implement interactive quiz screen with answer revealed state
This commit enhances the `QuizScreen` to be interactive, allowing users to select choices and view revealed answers. It also introduces HTML parsing for question text and adds new design elements like icons and colors.
Key changes:
- **UI Layer (`ui:quiz` module):**
    - `QuizScreen`:
        - Now takes an `onSelect` callback to handle choice selection.
        - `Choices` composable updated to display choices in a `LazyVerticalGrid` and handles click events.
        - Introduced `ChoiceItem` which branches into `ChoiceItemDefault` (for selectable choices) and `ChoiceItemRevealed` (for displaying correct/incorrect answers).
        - `ChoiceItemDefault` displays choices with background colors and icons based on their index.
        - `ChoiceItemRevealed` displays choices with background colors indicating correctness (green for correct, red for incorrect selected, pink for incorrect unselected) and appropriate icons (tick for correct, cross for wrong).
        - `QuestionContent` now parses HTML in the question text using `HtmlCompat` and a new `toAnnotatedString` extension.
        - Image loading in `QuestionContent` uses `ContentScale.FillWidth` and `heightIn(min = 200.dp)`.
        - Added a new preview `QuizScreenRevealedAnswerPreview` to showcase the revealed answer state.
    - `QuizScreenViewModel`:
        - Now manages `_selectedChoiceIndex` to track the user's answer.
        - `uiState` is now a combination of the fetched quiz and the `_selectedChoiceIndex`, producing `QuizUiState` which includes an `AnswerUiState`.
        - `AnswerUiState` holds the `selectedChoiceIndex`.
        - Implemented `onChoiceSelected(index: Int)` to update the selected choice.
- **Design System (`core:designsystem` module):**
    - Added `TextUtils.kt` with a `Spanned.toAnnotatedString()` extension function to convert HTML formatted text (from `HtmlCompat`) into Jetpack Compose `AnnotatedString`.
    - Added new color definitions: `Pink`, `Red`, `Red2`, `Blue2`, `Yellow3`, `Green`, `Green2`.
    - Added a `contrastiveTo(color: Color)` utility function to determine a contrasting text color (black or white) for a given background color.
    - Added new vector drawables for choice shapes and correctness indicators:
        - `ic_circle.xml`
        - `ic_correct.xml`
        - `ic_diamond.xml`
        - `ic_square.xml`
        - `ic_triangle.xml`
        - `ic_wrong.xml`
    - Added Detekt configuration file (`config/detekt/detekt.yml`) for the design system module.
- **Domain Layer (`domain` module):**
    - `Question.image` is now nullable (`String?`).
    - `Choice.correct` is now non-nullable (`Boolean`).
- **Network Layer (`core:network` module):**
    - `ChoiceDto.correct` is now non-nullable (`Boolean`) to align with the domain model.
- **Project Configuration:**
    - Added `.editorconfig` with Kotlin specific trailing comma settings.
    - Minor reordering of dependencies in `gradle/libs.versions.toml`.
			
			
This commit is contained in:
		| @@ -2,17 +2,24 @@ package dev.adriankuta.kahootquiz.ui.quiz | ||||
|  | ||||
| import androidx.compose.foundation.Image | ||||
| 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.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| 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.offset | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.size | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.foundation.lazy.grid.GridCells | ||||
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | ||||
| import androidx.compose.foundation.lazy.grid.itemsIndexed | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| @@ -26,11 +33,21 @@ import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.text.HtmlCompat | ||||
| import androidx.hilt.navigation.compose.hiltViewModel | ||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||
| import coil3.compose.AsyncImage | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Blue2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Green | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Green2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Grey | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Pink | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Red | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Red2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Yellow3 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString | ||||
| import dev.adriankuta.kahootquiz.domain.models.Choice | ||||
| import dev.adriankuta.kahootquiz.domain.models.Question | ||||
| import kotlin.time.Duration.Companion.seconds | ||||
| @@ -39,20 +56,22 @@ import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR | ||||
| @Composable | ||||
| fun QuizScreen( | ||||
|     modifier: Modifier = Modifier, | ||||
|     viewModel: QuizScreenViewModel = hiltViewModel() | ||||
|     viewModel: QuizScreenViewModel = hiltViewModel(), | ||||
| ) { | ||||
|  | ||||
|     val uiState by viewModel.uiState.collectAsStateWithLifecycle() | ||||
|  | ||||
|     QuizScreen( | ||||
|         uiState = uiState, | ||||
|         modifier = modifier.fillMaxSize() | ||||
|         onSelect = viewModel::onChoiceSelected, | ||||
|         modifier = modifier.fillMaxSize(), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun QuizScreen( | ||||
|     uiState: QuizUiState, | ||||
|     onSelect: (Int) -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Box(modifier.fillMaxSize()) { | ||||
| @@ -60,23 +79,27 @@ private fun QuizScreen( | ||||
|             painter = painterResource(id = DesignR.drawable.bg_image), | ||||
|             contentDescription = null, | ||||
|             contentScale = ContentScale.Crop, | ||||
|             modifier = Modifier.fillMaxSize() | ||||
|             modifier = Modifier.fillMaxSize(), | ||||
|         ) | ||||
|         Column( | ||||
|             modifier = Modifier.fillMaxSize() | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth(), | ||||
|         ) { | ||||
|             Toolbar( | ||||
|                 modifier = Modifier | ||||
|                     .fillMaxWidth() | ||||
|                     .height(72.dp) | ||||
|                     .padding(8.dp) | ||||
|                     .padding(8.dp), | ||||
|             ) | ||||
|             QuestionContent( | ||||
|                 question = uiState.currentQuestion ?: return, | ||||
|                 modifier = Modifier.padding(horizontal = 8.dp) | ||||
|                 modifier = Modifier.padding(horizontal = 8.dp), | ||||
|             ) | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|             Choices( | ||||
|                 choices = uiState.currentQuestion.choices ?: emptyList() // TODO remove empty list | ||||
|                 choices = uiState.currentQuestion.choices ?: emptyList(), // TODO remove empty list | ||||
|                 answer = uiState.answer, | ||||
|                 onSelect = onSelect, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| @@ -84,10 +107,10 @@ private fun QuizScreen( | ||||
|  | ||||
| @Composable | ||||
| private fun Toolbar( | ||||
|     modifier: Modifier = Modifier | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         Text( | ||||
|             text = "2/24", | ||||
| @@ -95,9 +118,9 @@ private fun Toolbar( | ||||
|                 .align(Alignment.CenterStart) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(60.dp) | ||||
|                     shape = RoundedCornerShape(60.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
|         ) | ||||
|  | ||||
|         Row( | ||||
| @@ -105,14 +128,14 @@ private fun Toolbar( | ||||
|                 .align(Alignment.Center) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(60.dp) | ||||
|                     shape = RoundedCornerShape(60.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
|         ) { | ||||
|             Image( | ||||
|                 painter = painterResource(id = DesignR.drawable.ic_type), | ||||
|                 contentDescription = "", | ||||
|                 modifier = Modifier.size(24.dp) | ||||
|                 modifier = Modifier.size(24.dp), | ||||
|             ) | ||||
|             Spacer(Modifier.width(4.dp)) | ||||
|             Text( | ||||
| @@ -128,38 +151,175 @@ private fun QuestionContent( | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Column( | ||||
|         modifier = modifier | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         AsyncImage( | ||||
|             model = question.image, | ||||
|             contentDescription = question.imageMetadata?.altText, | ||||
|             contentScale = ContentScale.Crop, | ||||
|             contentScale = ContentScale.FillWidth, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .height(200.dp) | ||||
|                 .heightIn(min = 200.dp), | ||||
|         ) | ||||
|         Spacer(Modifier.height(16.dp)) | ||||
|         Text( | ||||
|             text = question.question ?: "", | ||||
|             text = HtmlCompat.fromHtml( | ||||
|                 question.question ?: "", | ||||
|                 HtmlCompat.FROM_HTML_MODE_COMPACT, | ||||
|             ).toAnnotatedString(), | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .background( | ||||
|                     color = Color.White, | ||||
|                     shape = RoundedCornerShape(4.dp) | ||||
|                     shape = RoundedCornerShape(4.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 16.dp) | ||||
|                 .padding(horizontal = 8.dp, vertical = 16.dp), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun Choices( | ||||
|     choices: List<Choice> | ||||
|     choices: List<Choice>, | ||||
|     onSelect: (Int) -> Unit, | ||||
|     answer: AnswerUiState?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     LazyVerticalGrid() { } | ||||
|     LazyVerticalGrid( | ||||
|         columns = GridCells.Fixed(2), | ||||
|         contentPadding = PaddingValues(8.dp), | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|         verticalArrangement = Arrangement.spacedBy(8.dp), | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         itemsIndexed(choices) { index, choice -> | ||||
|             ChoiceItem( | ||||
|                 choice = choice, | ||||
|                 index = index, | ||||
|                 answer = answer, | ||||
|                 onClick = { onSelect(index) }, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItem( | ||||
|     choice: Choice, | ||||
|     onClick: () -> Unit, | ||||
|     index: Int, | ||||
|     answer: AnswerUiState?, | ||||
| ) { | ||||
|     if (answer != null) { | ||||
|         ChoiceItemRevealed( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             isSelected = answer.selectedChoiceIndex == index, | ||||
|         ) | ||||
|     } else { | ||||
|         ChoiceItemDefault( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             onClick = onClick, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemDefault( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     onClick: () -> Unit, | ||||
| ) { | ||||
|     val backgroundColor = when (index) { | ||||
|         0 -> Red2 | ||||
|         1 -> Blue2 | ||||
|         2 -> Yellow3 | ||||
|         3 -> Green2 | ||||
|         else -> Color.Gray | ||||
|     } | ||||
|  | ||||
|     // TODO Add icons | ||||
|     val icon = when (index) { | ||||
|         0 -> DesignR.drawable.ic_triangle | ||||
|         1 -> DesignR.drawable.ic_diamond | ||||
|         2 -> DesignR.drawable.ic_circle | ||||
|         else -> DesignR.drawable.ic_square | ||||
|     } | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp) | ||||
|             .clickable( | ||||
|                 onClick = onClick, | ||||
|             ), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(id = icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .padding(8.dp) | ||||
|                 .size(32.dp), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemRevealed( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     isSelected: Boolean, | ||||
| ) { | ||||
|     val backgroundColor = when { | ||||
|         isSelected && !choice.correct -> Red | ||||
|         choice.correct -> Green | ||||
|         else -> Pink | ||||
|     } | ||||
|  | ||||
|     val icon = if (choice.correct) { | ||||
|         DesignR.drawable.ic_correct | ||||
|     } else { | ||||
|         DesignR.drawable.ic_wrong | ||||
|     } | ||||
|  | ||||
|     val alignment = if (index % 2 == 0) { | ||||
|         Alignment.TopStart | ||||
|     } else { | ||||
|         Alignment.TopEnd | ||||
|     } | ||||
|  | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .align(alignment) | ||||
|                 .offset( | ||||
|                     x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp, | ||||
|                     (-8).dp, | ||||
|                 ), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| @Preview | ||||
| @Composable | ||||
| private fun QuizScreenPreview() { | ||||
| @@ -172,7 +332,7 @@ private fun QuizScreenPreview() { | ||||
|                 Choice(answer = "Berlin", correct = false), | ||||
|                 Choice(answer = "Madrid", correct = false), | ||||
|                 Choice(answer = "Paris", correct = true), | ||||
|                 Choice(answer = "Rome", correct = false) | ||||
|                 Choice(answer = "Rome", correct = false), | ||||
|             ), | ||||
|             pointsMultiplier = 1, | ||||
|             time = 30.seconds, | ||||
| @@ -180,7 +340,41 @@ private fun QuizScreenPreview() { | ||||
|             imageMetadata = null, | ||||
|         ) | ||||
|         QuizScreen( | ||||
|             uiState = QuizUiState(currentQuestion = sampleQuestion) | ||||
|             uiState = QuizUiState( | ||||
|                 currentQuestion = sampleQuestion, | ||||
|             ), | ||||
|             onSelect = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Preview | ||||
| @Composable | ||||
| private fun QuizScreenRevealedAnswerPreview() { | ||||
|     KahootQuizTheme { | ||||
|         val sampleQuestion = Question( | ||||
|             type = "quiz", | ||||
|             image = "", // Add a sample image URL or leave empty | ||||
|             question = "What is the capital of France?", | ||||
|             choices = listOf( | ||||
|                 Choice(answer = "Berlin", correct = false), | ||||
|                 Choice(answer = "Madrid", correct = false), | ||||
|                 Choice(answer = "Paris", correct = true), | ||||
|                 Choice(answer = "Rome", correct = false), | ||||
|             ), | ||||
|             pointsMultiplier = 1, | ||||
|             time = 30.seconds, | ||||
|             questionFormat = 0, | ||||
|             imageMetadata = null, | ||||
|         ) | ||||
|         QuizScreen( | ||||
|             uiState = QuizUiState( | ||||
|                 currentQuestion = sampleQuestion, | ||||
|                 answer = AnswerUiState( | ||||
|                     selectedChoiceIndex = 1, | ||||
|                 ), | ||||
|             ), | ||||
|             onSelect = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -6,26 +6,42 @@ import dagger.hilt.android.lifecycle.HiltViewModel | ||||
| import dev.adriankuta.kahootquiz.domain.models.Question | ||||
| import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.SharingStarted | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.asStateFlow | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.flow.asFlow | ||||
| import kotlinx.coroutines.flow.combine | ||||
| import kotlinx.coroutines.flow.stateIn | ||||
| import javax.inject.Inject | ||||
|  | ||||
| @HiltViewModel | ||||
| class QuizScreenViewModel @Inject constructor( | ||||
|     private val getQuizUseCase: GetQuizUseCase | ||||
|     private val getQuizUseCase: GetQuizUseCase, | ||||
| ) : ViewModel() { | ||||
|     private val _uiState = MutableStateFlow(QuizUiState()) | ||||
|     val uiState: StateFlow<QuizUiState> = _uiState.asStateFlow() | ||||
|     private val _selectedChoiceIndex = MutableStateFlow<Int?>(null) | ||||
|     val uiState: StateFlow<QuizUiState> = combine( | ||||
|         suspend { getQuizUseCase() }.asFlow(), | ||||
|         _selectedChoiceIndex, | ||||
|     ) { quiz, selectedChoiceIndex -> | ||||
|         QuizUiState( | ||||
|             currentQuestion = quiz.questions.first(), | ||||
|             answer = selectedChoiceIndex?.let { AnswerUiState(it) }, | ||||
|         ) | ||||
|     }.stateIn( | ||||
|         scope = viewModelScope, | ||||
|         started = SharingStarted.WhileSubscribed(5000), | ||||
|         initialValue = QuizUiState(), | ||||
|     ) | ||||
|  | ||||
|     init { | ||||
|         viewModelScope.launch { | ||||
|             _uiState.value = QuizUiState(getQuizUseCase().questions.first()) | ||||
|         } | ||||
|     fun onChoiceSelected(index: Int) { | ||||
|         _selectedChoiceIndex.value = index | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| data class QuizUiState( | ||||
|     val currentQuestion: Question? = null | ||||
| ) | ||||
|     val currentQuestion: Question? = null, | ||||
|     val answer: AnswerUiState? = null, | ||||
| ) | ||||
|  | ||||
| data class AnswerUiState( | ||||
|     val selectedChoiceIndex: Int, | ||||
| ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user