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:
2025-09-03 23:45:29 +02:00
parent 45550ecf76
commit 7568abb775
16 changed files with 474 additions and 44 deletions

4
.editorconfig Executable file
View File

@@ -0,0 +1,4 @@
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true

View File

@@ -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

View File

@@ -1,6 +1,15 @@
package dev.adriankuta.kahootquiz.core.designsystem package dev.adriankuta.kahootquiz.core.designsystem
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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 Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC) val PurpleGrey80 = Color(0xFFCCC2DC)
@@ -10,4 +19,11 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
val Grey = Color(0xFFFAFAFA) 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)

View File

@@ -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,
)
}
}
}

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M20,10C14.477,10 10,14.477 10,20C10,25.523 14.477,30 20,30C25.523,30 30,25.523 30,20C30,14.477 25.523,10 20,10Z"
android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M20,9.5C25.799,9.5 30.5,14.201 30.5,20C30.5,25.799 25.799,30.5 20,30.5C14.201,30.5 9.5,25.799 9.5,20C9.5,14.201 14.201,9.5 20,9.5Z"
android:strokeAlpha="0.15"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeWidth="2"
android:fillColor="#67BF38"
android:strokeColor="#26890C"/>
<path
android:pathData="M25.237,12.219L28.298,14.723L18.157,27.123L11.651,21.662L14.296,18.722L17.722,21.409L25.237,12.219Z"
android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M25.554,11.831L28.615,14.336L29.001,14.653L28.685,15.039L18.545,27.44L18.224,27.832L17.836,27.507L11.33,22.045L10.933,21.713L11.279,21.328L13.925,18.388L14.237,18.041L14.604,18.329L17.645,20.713L24.85,11.902L25.167,11.515L25.554,11.831Z"
android:strokeAlpha="0.15"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<group>
<clip-path
android:pathData="M25.237,12.219L28.298,14.723L18.157,27.123L11.651,21.662L14.296,18.722L17.722,21.409L25.237,12.219ZM25.554,11.831L28.615,14.336L29.001,14.653L28.685,15.039L18.545,27.44L18.224,27.832L17.836,27.507L11.33,22.045L10.933,21.713L11.279,21.328L13.925,18.388L14.237,18.041L14.604,18.329L17.645,20.713L24.85,11.902L25.167,11.515L25.554,11.831Z"/>
</group>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M20,8.75L8.75,20.004L20,31.25L31.25,20.001L20,8.75Z"
android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M20.354,8.396L31.604,19.647L31.957,20.001L31.604,20.354L20.354,31.604L20,31.957L19.646,31.604L8.396,20.357L8.043,20.004L8.396,19.65L19.646,8.396L20,8.043L20.354,8.396Z"
android:strokeAlpha="0.15"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="41dp"
android:viewportWidth="40"
android:viewportHeight="41">
<path
android:pathData="M28.75,11.476H11.25V28.976H28.75V11.476Z"
android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M29.25,10.976V29.476H10.75V10.976H29.25Z"
android:strokeAlpha="0.15"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M20,11.25L8.75,28.75H31.25L20,11.25Z"
android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M20.421,10.979L31.671,28.479L32.166,29.25H7.834L8.329,28.479L19.579,10.979L20,10.325L20.421,10.979Z"
android:strokeAlpha="0.15"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,35 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:fillColor="#FF3355"/>
<path
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#FF3355"/>
<path
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeAlpha="0.15"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M12.04,24.636L15.355,27.95L19.995,23.31L24.636,27.95L27.95,24.636L23.31,19.995L27.95,15.355L24.636,12.04L19.995,16.681L15.355,12.04L12.04,15.355L16.681,19.995L12.04,24.636Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<group>
<clip-path
android:pathData="M5.853,5.721h28.284v28.284h-28.284z"/>
<clip-path
android:pathData="M12.04,24.636L15.355,27.95L19.995,23.31L24.636,27.95L27.95,24.636L23.31,19.995L27.95,15.355L24.636,12.04L19.995,16.681L15.355,12.04L12.04,15.355L16.681,19.995L12.04,24.636Z"
android:fillType="evenOdd"/>
<path
android:pathData="M15.355,27.95L14.648,28.657L15.355,29.365L16.062,28.657L15.355,27.95ZM12.04,24.636L11.333,23.929L10.626,24.636L11.333,25.343L12.04,24.636ZM19.995,23.31L20.702,22.603L19.995,21.896L19.288,22.603L19.995,23.31ZM24.636,27.95L23.929,28.657L24.636,29.365L25.343,28.657L24.636,27.95ZM27.95,24.636L28.657,25.343L29.365,24.636L28.657,23.929L27.95,24.636ZM23.31,19.995L22.603,19.288L21.896,19.995L22.603,20.702L23.31,19.995ZM27.95,15.355L28.657,16.062L29.365,15.355L28.657,14.648L27.95,15.355ZM24.636,12.04L25.343,11.333L24.636,10.626L23.929,11.333L24.636,12.04ZM19.995,16.681L19.288,17.388L19.995,18.095L20.702,17.388L19.995,16.681ZM15.355,12.04L16.062,11.333L15.355,10.626L14.648,11.333L15.355,12.04ZM12.04,15.355L11.333,14.648L10.626,15.355L11.333,16.062L12.04,15.355ZM16.681,19.995L17.388,20.702L18.095,19.995L17.388,19.288L16.681,19.995ZM16.062,27.243L12.748,23.929L11.333,25.343L14.648,28.657L16.062,27.243ZM19.288,22.603L14.648,27.243L16.062,28.657L20.702,24.017L19.288,22.603ZM25.343,27.243L20.702,22.603L19.288,24.017L23.929,28.657L25.343,27.243ZM27.243,23.929L23.929,27.243L25.343,28.657L28.657,25.343L27.243,23.929ZM22.603,20.702L27.243,25.343L28.657,23.929L24.017,19.288L22.603,20.702ZM27.243,14.648L22.603,19.288L24.017,20.702L28.657,16.062L27.243,14.648ZM23.929,12.748L27.243,16.062L28.657,14.648L25.343,11.333L23.929,12.748ZM20.702,17.388L25.343,12.748L23.929,11.333L19.288,15.974L20.702,17.388ZM14.648,12.748L19.288,17.388L20.702,15.974L16.062,11.333L14.648,12.748ZM12.748,16.062L16.062,12.748L14.648,11.333L11.333,14.648L12.748,16.062ZM17.388,19.288L12.748,14.648L11.333,16.062L15.974,20.702L17.388,19.288ZM12.748,25.343L17.388,20.702L15.974,19.288L11.333,23.929L12.748,25.343Z"
android:fillColor="#000000"
android:fillAlpha="0.15"/>
</group>
</vector>

View File

@@ -24,7 +24,7 @@ data class QuestionDto(
data class ChoiceDto( data class ChoiceDto(
val answer: String?, val answer: String?,
val correct: Boolean?, val correct: Boolean,
val languageInfo: LanguageInfoDto? val languageInfo: LanguageInfoDto?
) )

View File

@@ -2,6 +2,6 @@ package dev.adriankuta.kahootquiz.domain.models
data class Choice( data class Choice(
val answer: String?, val answer: String?,
val correct: Boolean?, val correct: Boolean,
val languageInfo: LanguageInfo? = null val languageInfo: LanguageInfo? = null
) )

View File

@@ -12,12 +12,12 @@ data class Question(
val pointsMultiplier: Int?, val pointsMultiplier: Int?,
val choices: List<Choice>?, val choices: List<Choice>?,
val layout: String? = null, val layout: String? = null,
val image: String, val image: String? = null,
val imageMetadata: ImageMetadata?, val imageMetadata: ImageMetadata?,
val resources: String? = null, val resources: String? = null,
val video: Video? = null, val video: Video? = null,
val questionFormat: Int? = null, val questionFormat: Int? = null,
val languageInfo: LanguageInfo? = null, val languageInfo: LanguageInfo? = null,
val media: List<MediaItem>? = null, val media: List<MediaItem>? = null,
val choiceRange: ChoiceRange? = null val choiceRange: ChoiceRange? = null,
) )

View File

@@ -1,7 +1,4 @@
[versions] [versions]
coilCompose = "3.3.0"
coilNetworkOkhttp = "3.3.0"
retrofit = "3.0.0"
targetSdk = "36" targetSdk = "36"
compileSdk = "36" compileSdk = "36"
minSdk = "23" minSdk = "23"
@@ -23,6 +20,8 @@ animation = "1.9.0"
appUpdateKtx = "2.1.0" appUpdateKtx = "2.1.0"
appcompat = "1.7.1" appcompat = "1.7.1"
billing = "8.0.0" billing = "8.0.0"
coilCompose = "3.3.0"
coilNetworkOkhttp = "3.3.0"
coreTest = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/test 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 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 datetime = "0.7.1" # https://github.com/Kotlin/kotlinx-datetime/releases
@@ -48,6 +47,7 @@ material = "1.12.0"
materialIconsExtended = "1.7.8" materialIconsExtended = "1.7.8"
mockk = "1.14.5" # https://github.com/mockk/mockk/releases mockk = "1.14.5" # https://github.com/mockk/mockk/releases
playServicesAds = "24.5.0" playServicesAds = "24.5.0"
retrofit = "3.0.0"
reviewKtx = "2.0.2" reviewKtx = "2.0.2"
room = "2.7.2" room = "2.7.2"
secrets = "2.0.1" secrets = "2.0.1"

View File

@@ -2,17 +2,24 @@ package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage 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.Grey
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme 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.Choice
import dev.adriankuta.kahootquiz.domain.models.Question import dev.adriankuta.kahootquiz.domain.models.Question
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -39,20 +56,22 @@ import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
@Composable @Composable
fun QuizScreen( fun QuizScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: QuizScreenViewModel = hiltViewModel() viewModel: QuizScreenViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
QuizScreen( QuizScreen(
uiState = uiState, uiState = uiState,
modifier = modifier.fillMaxSize() onSelect = viewModel::onChoiceSelected,
modifier = modifier.fillMaxSize(),
) )
} }
@Composable @Composable
private fun QuizScreen( private fun QuizScreen(
uiState: QuizUiState, uiState: QuizUiState,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
@@ -60,23 +79,27 @@ private fun QuizScreen(
painter = painterResource(id = DesignR.drawable.bg_image), painter = painterResource(id = DesignR.drawable.bg_image),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
) )
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth(),
) { ) {
Toolbar( Toolbar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(72.dp) .height(72.dp)
.padding(8.dp) .padding(8.dp),
) )
QuestionContent( QuestionContent(
question = uiState.currentQuestion ?: return, question = uiState.currentQuestion ?: return,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp),
) )
Spacer(Modifier.height(8.dp))
Choices( 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 @Composable
private fun Toolbar( private fun Toolbar(
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
Box( Box(
modifier = modifier modifier = modifier,
) { ) {
Text( Text(
text = "2/24", text = "2/24",
@@ -95,9 +118,9 @@ private fun Toolbar(
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.background( .background(
color = Grey, 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( Row(
@@ -105,14 +128,14 @@ private fun Toolbar(
.align(Alignment.Center) .align(Alignment.Center)
.background( .background(
color = Grey, 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( Image(
painter = painterResource(id = DesignR.drawable.ic_type), painter = painterResource(id = DesignR.drawable.ic_type),
contentDescription = "", contentDescription = "",
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp),
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text( Text(
@@ -128,38 +151,175 @@ private fun QuestionContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier modifier = modifier,
) { ) {
AsyncImage( AsyncImage(
model = question.image, model = question.image,
contentDescription = question.imageMetadata?.altText, contentDescription = question.imageMetadata?.altText,
contentScale = ContentScale.Crop, contentScale = ContentScale.FillWidth,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .heightIn(min = 200.dp),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = question.question ?: "", text = HtmlCompat.fromHtml(
question.question ?: "",
HtmlCompat.FROM_HTML_MODE_COMPACT,
).toAnnotatedString(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
color = Color.White, 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 @Composable
private fun Choices( 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 @Preview
@Composable @Composable
private fun QuizScreenPreview() { private fun QuizScreenPreview() {
@@ -172,7 +332,7 @@ private fun QuizScreenPreview() {
Choice(answer = "Berlin", correct = false), Choice(answer = "Berlin", correct = false),
Choice(answer = "Madrid", correct = false), Choice(answer = "Madrid", correct = false),
Choice(answer = "Paris", correct = true), Choice(answer = "Paris", correct = true),
Choice(answer = "Rome", correct = false) Choice(answer = "Rome", correct = false),
), ),
pointsMultiplier = 1, pointsMultiplier = 1,
time = 30.seconds, time = 30.seconds,
@@ -180,7 +340,41 @@ private fun QuizScreenPreview() {
imageMetadata = null, imageMetadata = null,
) )
QuizScreen( 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 = {},
) )
} }
} }

View File

@@ -6,26 +6,42 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.kahootquiz.domain.models.Question import dev.adriankuta.kahootquiz.domain.models.Question
import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class QuizScreenViewModel @Inject constructor( class QuizScreenViewModel @Inject constructor(
private val getQuizUseCase: GetQuizUseCase private val getQuizUseCase: GetQuizUseCase,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(QuizUiState()) private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
val uiState: StateFlow<QuizUiState> = _uiState.asStateFlow() 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 { fun onChoiceSelected(index: Int) {
viewModelScope.launch { _selectedChoiceIndex.value = index
_uiState.value = QuizUiState(getQuizUseCase().questions.first())
}
} }
} }
data class QuizUiState( data class QuizUiState(
val currentQuestion: Question? = null val currentQuestion: Question? = null,
) val answer: AnswerUiState? = null,
)
data class AnswerUiState(
val selectedChoiceIndex: Int,
)