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:
		
							
								
								
									
										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) | ||||
| @@ -11,3 +20,10 @@ val PurpleGrey40 = Color(0xFF625b71) | ||||
| val Pink40 = Color(0xFF7D5260) | ||||
|  | ||||
| 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,37 +151,174 @@ 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 | ||||
| @@ -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