mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-14 09:15:59 +02: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:
4
.editorconfig
Executable file
4
.editorconfig
Executable file
@@ -0,0 +1,4 @@
|
||||
[*.{kt,kts}]
|
||||
ij_kotlin_allow_trailing_comma = true
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||
|
33
core/designsystem/config/detekt/detekt.yml
Normal file
33
core/designsystem/config/detekt/detekt.yml
Normal 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
|
@@ -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)
|
||||
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)
|
||||
|
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
15
core/designsystem/src/main/res/drawable/ic_circle.xml
Normal file
15
core/designsystem/src/main/res/drawable/ic_circle.xml
Normal 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>
|
24
core/designsystem/src/main/res/drawable/ic_correct.xml
Normal file
24
core/designsystem/src/main/res/drawable/ic_correct.xml
Normal 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>
|
15
core/designsystem/src/main/res/drawable/ic_diamond.xml
Normal file
15
core/designsystem/src/main/res/drawable/ic_diamond.xml
Normal 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>
|
15
core/designsystem/src/main/res/drawable/ic_square.xml
Normal file
15
core/designsystem/src/main/res/drawable/ic_square.xml
Normal 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>
|
15
core/designsystem/src/main/res/drawable/ic_triangle.xml
Normal file
15
core/designsystem/src/main/res/drawable/ic_triangle.xml
Normal 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>
|
35
core/designsystem/src/main/res/drawable/ic_wrong.xml
Normal file
35
core/designsystem/src/main/res/drawable/ic_wrong.xml
Normal 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>
|
@@ -24,7 +24,7 @@ data class QuestionDto(
|
||||
|
||||
data class ChoiceDto(
|
||||
val answer: String?,
|
||||
val correct: Boolean?,
|
||||
val correct: Boolean,
|
||||
val languageInfo: LanguageInfoDto?
|
||||
)
|
||||
|
||||
|
@@ -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
|
||||
)
|
@@ -12,12 +12,12 @@ data class Question(
|
||||
val pointsMultiplier: Int?,
|
||||
val choices: List<Choice>?,
|
||||
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<MediaItem>? = null,
|
||||
val choiceRange: ChoiceRange? = null
|
||||
val choiceRange: ChoiceRange? = null,
|
||||
)
|
@@ -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"
|
||||
|
@@ -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