From 7568abb775a7ca43bf7ec7e781fe81e7434890ef Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 3 Sep 2025 23:45:29 +0200 Subject: [PATCH] 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`. --- .editorconfig | 4 + core/designsystem/config/detekt/detekt.yml | 33 +++ .../kahootquiz/core/designsystem/Color.kt | 18 +- .../kahootquiz/core/designsystem/TextUtils.kt | 48 ++++ .../src/main/res/drawable/ic_circle.xml | 15 ++ .../src/main/res/drawable/ic_correct.xml | 24 ++ .../src/main/res/drawable/ic_diamond.xml | 15 ++ .../src/main/res/drawable/ic_square.xml | 15 ++ .../src/main/res/drawable/ic_triangle.xml | 15 ++ .../src/main/res/drawable/ic_wrong.xml | 35 +++ .../core/network/models/QuestionDtos.kt | 2 +- .../kahootquiz/domain/models/Choice.kt | 2 +- .../kahootquiz/domain/models/Question.kt | 4 +- gradle/libs.versions.toml | 6 +- .../kahootquiz/ui/quiz/QuizScreen.kt | 242 ++++++++++++++++-- .../kahootquiz/ui/quiz/QuizScreenViewModel.kt | 40 ++- 16 files changed, 474 insertions(+), 44 deletions(-) create mode 100755 .editorconfig create mode 100644 core/designsystem/config/detekt/detekt.yml create mode 100644 core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/TextUtils.kt create mode 100644 core/designsystem/src/main/res/drawable/ic_circle.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_correct.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_diamond.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_square.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_triangle.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_wrong.xml diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..3f61fc2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true + diff --git a/core/designsystem/config/detekt/detekt.yml b/core/designsystem/config/detekt/detekt.yml new file mode 100644 index 0000000..aba4110 --- /dev/null +++ b/core/designsystem/config/detekt/detekt.yml @@ -0,0 +1,33 @@ +# Exceptions for compose. See https://detekt.dev/docs/introduction/compose +naming: + FunctionNaming: + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9]*' + +complexity: + LongParameterList: + ignoreAnnotated: ['Composable'] + TooManyFunctions: + ignoreAnnotatedFunctions: ['Preview'] + +style: + MagicNumber: + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotated: ['Composable'] + + UnusedPrivateMember: + ignoreAnnotated: ['Composable'] + +# Deviations from defaults +formatting: + TrailingCommaOnCallSite: + active: true + autoCorrect: true + useTrailingCommaOnCallSite: true + TrailingCommaOnDeclarationSite: + active: true + autoCorrect: true + useTrailingCommaOnDeclarationSite: true \ No newline at end of file diff --git a/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/Color.kt b/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/Color.kt index a83aa14..0242dce 100644 --- a/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/Color.kt +++ b/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/Color.kt @@ -1,6 +1,15 @@ package dev.adriankuta.kahootquiz.core.designsystem +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance + +@Composable +fun contrastiveTo(color: Color): Color = if (color.luminance() < 0.5) { + Color.White +} else { + Color.Black +} val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) @@ -10,4 +19,11 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) -val Grey = Color(0xFFFAFAFA) \ No newline at end of file +val Grey = Color(0xFFFAFAFA) +val Pink = Color(0xFFFF99AA) +val Red = Color(0xFFFF3355) +val Red2 = Color(0xFFE21B3C) +val Blue2 = Color(0xFF1368CE) +val Yellow3 = Color(0xFFD89E00) +val Green = Color(0xFF66BF39) +val Green2 = Color(0xFF26890C) diff --git a/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/TextUtils.kt b/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/TextUtils.kt new file mode 100644 index 0000000..a15e7e4 --- /dev/null +++ b/core/designsystem/src/main/kotlin/dev/adriankuta/kahootquiz/core/designsystem/TextUtils.kt @@ -0,0 +1,48 @@ +package dev.adriankuta.kahootquiz.core.designsystem + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + ), + start, end, + ) + } + + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end, + ) + + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(span.foregroundColor)), + start, + end, + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/ic_circle.xml b/core/designsystem/src/main/res/drawable/ic_circle.xml new file mode 100644 index 0000000..8790a7f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_correct.xml b/core/designsystem/src/main/res/drawable/ic_correct.xml new file mode 100644 index 0000000..8eb3548 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_correct.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_diamond.xml b/core/designsystem/src/main/res/drawable/ic_diamond.xml new file mode 100644 index 0000000..114eca9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_diamond.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_square.xml b/core/designsystem/src/main/res/drawable/ic_square.xml new file mode 100644 index 0000000..8bd8e54 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_square.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_triangle.xml b/core/designsystem/src/main/res/drawable/ic_triangle.xml new file mode 100644 index 0000000..cd19e6f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_triangle.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_wrong.xml b/core/designsystem/src/main/res/drawable/ic_wrong.xml new file mode 100644 index 0000000..91359ed --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_wrong.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/core/network/src/main/kotlin/dev/adriankuta/kahootquiz/core/network/models/QuestionDtos.kt b/core/network/src/main/kotlin/dev/adriankuta/kahootquiz/core/network/models/QuestionDtos.kt index f1d9e08..ee956f5 100644 --- a/core/network/src/main/kotlin/dev/adriankuta/kahootquiz/core/network/models/QuestionDtos.kt +++ b/core/network/src/main/kotlin/dev/adriankuta/kahootquiz/core/network/models/QuestionDtos.kt @@ -24,7 +24,7 @@ data class QuestionDto( data class ChoiceDto( val answer: String?, - val correct: Boolean?, + val correct: Boolean, val languageInfo: LanguageInfoDto? ) diff --git a/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Choice.kt b/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Choice.kt index 26c8536..0023607 100644 --- a/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Choice.kt +++ b/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Choice.kt @@ -2,6 +2,6 @@ package dev.adriankuta.kahootquiz.domain.models data class Choice( val answer: String?, - val correct: Boolean?, + val correct: Boolean, val languageInfo: LanguageInfo? = null ) \ No newline at end of file diff --git a/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Question.kt b/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Question.kt index 2be76a2..ec8a595 100644 --- a/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Question.kt +++ b/domain/src/main/kotlin/dev/adriankuta/kahootquiz/domain/models/Question.kt @@ -12,12 +12,12 @@ data class Question( val pointsMultiplier: Int?, val choices: List?, val layout: String? = null, - val image: String, + val image: String? = null, val imageMetadata: ImageMetadata?, val resources: String? = null, val video: Video? = null, val questionFormat: Int? = null, val languageInfo: LanguageInfo? = null, val media: List? = null, - val choiceRange: ChoiceRange? = null + val choiceRange: ChoiceRange? = null, ) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7943af..bee7a25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,4 @@ [versions] -coilCompose = "3.3.0" -coilNetworkOkhttp = "3.3.0" -retrofit = "3.0.0" targetSdk = "36" compileSdk = "36" minSdk = "23" @@ -23,6 +20,8 @@ animation = "1.9.0" appUpdateKtx = "2.1.0" appcompat = "1.7.1" billing = "8.0.0" +coilCompose = "3.3.0" +coilNetworkOkhttp = "3.3.0" coreTest = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/test datastorePreferences = "1.1.7" # https://developer.android.com/topic/libraries/architecture/datastore#preferences-datastore-dependencies datetime = "0.7.1" # https://github.com/Kotlin/kotlinx-datetime/releases @@ -48,6 +47,7 @@ material = "1.12.0" materialIconsExtended = "1.7.8" mockk = "1.14.5" # https://github.com/mockk/mockk/releases playServicesAds = "24.5.0" +retrofit = "3.0.0" reviewKtx = "2.0.2" room = "2.7.2" secrets = "2.0.1" diff --git a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt index 89529f4..eecec68 100644 --- a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt +++ b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt @@ -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 + choices: List, + 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 = {}, ) } } diff --git a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt index 1249682..9e2ebad 100644 --- a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt +++ b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt @@ -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 = _uiState.asStateFlow() + private val _selectedChoiceIndex = MutableStateFlow(null) + val uiState: StateFlow = 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 -) \ No newline at end of file + val currentQuestion: Question? = null, + val answer: AnswerUiState? = null, +) + +data class AnswerUiState( + val selectedChoiceIndex: Int, +)