mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-14 17:24:21 +02:00
Compare commits
16 Commits
710dedb0cc
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
75d1ce86eb | ||
![]() |
d6e77be660 | ||
![]() |
b454701566 | ||
![]() |
34b026ec94 | ||
![]() |
1b57800641 | ||
![]() |
21ba338d38 | ||
![]() |
59218cc2e1 | ||
![]() |
77a3dd9eeb | ||
![]() |
99f1c49713 | ||
![]() |
12638f33d8 | ||
![]() |
3194d2a813 | ||
![]() |
7cd3394098 | ||
![]() |
7d38facda5 | ||
![]() |
d2fce7e7b9 | ||
![]() |
41fd729271 | ||
![]() |
f0bd963d2d |
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -28,9 +28,8 @@
|
||||
<option value="$PROJECT_DIR$/core" />
|
||||
<option value="$PROJECT_DIR$/core/designsystem" />
|
||||
<option value="$PROJECT_DIR$/core/network" />
|
||||
<option value="$PROJECT_DIR$/data" />
|
||||
<option value="$PROJECT_DIR$/domain" />
|
||||
<option value="$PROJECT_DIR$/model" />
|
||||
<option value="$PROJECT_DIR$/model/data" />
|
||||
<option value="$PROJECT_DIR$/ui" />
|
||||
<option value="$PROJECT_DIR$/ui/quiz" />
|
||||
</set>
|
||||
|
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# KahootQuiz — Interview Challenge
|
||||
|
||||
This project is an implementation for an interview-style challenge. It demonstrates a clean, modular
|
||||
Android architecture with a focus on separation of concerns, convention plugins for Gradle, and
|
||||
pragmatic Kotlin usage.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Only image media is supported right now.
|
||||
- Slider question type is not supported.
|
||||
- There is no end/completion screen yet.
|
||||
- Errors in ViewModels are caught but not yet handled (no user-facing error states/actions).
|
||||
|
||||
## Project Overview
|
||||
|
||||
- Multi-module, clean architecture:
|
||||
- `core/` — common utilities (e.g., networking).
|
||||
- `domain/` — pure domain models and repository abstractions, domain models.
|
||||
- `data/` — repository implementations, mappers.
|
||||
- `ui/` — feature UI modules (e.g., `ui/quiz`).
|
||||
- Convention plugins are used to centralize and reuse Gradle configuration across modules (see
|
||||
`build-logic/`).
|
||||
- Kotlin-first approach using language features to keep code concise and readable.
|
||||
|
||||
## How to Build & Run
|
||||
|
||||
1. Requirements:
|
||||
- Android Studio
|
||||
- JDK 21
|
||||
- Gradle wrapper included
|
||||
2. Steps:
|
||||
- Open the project in Android Studio.
|
||||
- Sync Gradle.
|
||||
- Run the `app` configuration on a device/emulator.
|
||||
|
||||
If you prefer the command line: `./gradlew assembleDebug` and then install the generated APK.
|
||||
|
||||
## Architecture Details
|
||||
|
||||
- Data flow follows a standard clean pattern:
|
||||
- `domain.repositories.QuizRepository` defines the contract.
|
||||
- `data.QuizRepositoryImpl` uses `core.network.retrofit.QuizApi` + mappers to produce
|
||||
`domain.models.Quiz`.
|
||||
- UI consumes domain via ViewModels and exposes a `UiState`.
|
||||
- The code emphasizes separation of concerns and testability.
|
||||
|
||||
## Current Limitations & Known Issues
|
||||
|
||||
- Media support:
|
||||
- Only `image` media is supported in the quiz content.
|
||||
- Other media types are not supported.
|
||||
- Question types:
|
||||
- Slider answers are not supported yet.
|
||||
- UX flow:
|
||||
- There is no end/completion screen after the quiz finishes.
|
||||
- Error handling:
|
||||
- Exceptions are caught in ViewModels but not handled (no retry, no error UI, no telemetry hooks
|
||||
yet).
|
||||
|
||||
## Suggested Improvements
|
||||
|
||||
1. Introduce a UI-specific model for the Quiz screen
|
||||
- The domain model `Quiz` is relatively complex and currently used directly in `UiState`.
|
||||
- Add a dedicated, lean UI data class that contains only the data relevant to the quiz screen.
|
||||
- Benefits: Improved clarity for UI developers, simpler previews, easier testing/mocking, and
|
||||
better forward-compatibility when domain evolves.
|
||||
|
||||
2. Expand Unit Test Coverage
|
||||
- Currently there is only one unit test for parsing a sample JSON API response.
|
||||
- Add tests for:
|
||||
- ViewModel state transitions (loading/success/error).
|
||||
- Mapping edge cases (e.g., missing fields, unsupported media types).
|
||||
- Navigation/flow for various question types.
|
||||
|
||||
3. Error Handling Strategy
|
||||
- Map exceptions to user-friendly UI states with retry actions.
|
||||
- Add telemetry/logging hooks for observability.
|
||||
|
||||
4. Feature Completeness
|
||||
- Implement slider answer type.
|
||||
- Add an end/completion screen with score summary and restart/share options.
|
||||
- Consider support for additional media types (video/audio), with graceful fallbacks.
|
||||
5. Transitions between questions could be more smooth.
|
||||
|
||||
## Extra: Related Work I Can Share
|
||||
|
||||
I can share more complex code from my private app that is published on the Google Play Store.
|
||||
Additionally, I have a secondary project — an AI Agent implemented in TypeScript using Google’s
|
||||
GenKit framework — that prepares content for that app. It leverages multiple models, vector stores,
|
||||
and embeddings to orchestrate cooperative behaviors.
|
||||
|
||||
If you’re interested, I can provide a deeper walkthrough, architectural diagrams, or selected code
|
||||
excerpts.
|
||||
|
||||
## License
|
||||
|
||||
This repository is provided as-is for interview and demonstration purposes.
|
@@ -29,7 +29,7 @@ android {
|
||||
dependencies {
|
||||
implementation(projects.core.designsystem)
|
||||
implementation(projects.domain)
|
||||
implementation(projects.model.data)
|
||||
implementation(projects.data)
|
||||
implementation(projects.ui.quiz)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
158
app/lint-baseline.xml
Normal file
158
app/lint-baseline.xml
Normal file
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `endX` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:endX="85.84757""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="10"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `endY` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:endY="92.4963""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="11"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `startX` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:startX="42.9492""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="12"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `startY` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:startY="49.59793""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="13"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `offset` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:offset="0.0" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="17"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `offset` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:offset="1.0" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="20"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="nonZero""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
line="26"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.purple_200` appears to be unused"
|
||||
errorLine1=" <color name="purple_200">#FFBB86FC</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="3"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.purple_500` appears to be unused"
|
||||
errorLine1=" <color name="purple_500">#FF6200EE</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="4"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.purple_700` appears to be unused"
|
||||
errorLine1=" <color name="purple_700">#FF3700B3</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="5"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.teal_200` appears to be unused"
|
||||
errorLine1=" <color name="teal_200">#FF03DAC5</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="6"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.teal_700` appears to be unused"
|
||||
errorLine1=" <color name="teal_700">#FF018786</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="7"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.black` appears to be unused"
|
||||
errorLine1=" <color name="black">#FF000000</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="8"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.white` appears to be unused"
|
||||
errorLine1=" <color name="white">#FFFFFFFF</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
@@ -1,11 +1,16 @@
|
||||
package dev.adriankuta.kahootquiz
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
|
||||
|
||||
@Composable
|
||||
fun KahootQuizApp(
|
||||
@@ -15,6 +20,12 @@ fun KahootQuizApp(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
KahootQuizNavGraph(modifier = modifier.padding(paddingValues))
|
||||
Image(
|
||||
painter = painterResource(id = DesignR.drawable.bg_image),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
KahootQuizNavGraph(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,4 +20,4 @@ fun KahootQuizNavGraph(
|
||||
) {
|
||||
quizScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -13,4 +13,4 @@ class ExampleUnitTest {
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
176
core/designsystem/lint-baseline.xml
Normal file
176
core/designsystem/lint-baseline.xml
Normal file
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="8"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="12"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="16"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="20"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="24"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="28"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="32"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="36"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="40"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="44"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_type.xml"
|
||||
line="48"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_wrong.xml"
|
||||
line="22"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `fillType` is only used in API level 24 and higher (current min is 23)"
|
||||
errorLine1=" android:fillType="evenOdd""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_wrong.xml"
|
||||
line="27"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="VectorRaster"
|
||||
message="This tag is not supported in images generated from this vector icon for API < 24; check generated icon to make sure it looks acceptable"
|
||||
errorLine1=" <clip-path android:pathData="M5.853,5.721h28.284v28.284h-28.284z" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_wrong.xml"
|
||||
line="25"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="VectorRaster"
|
||||
message="This tag is not supported in images generated from this vector icon for API < 24; check generated icon to make sure it looks acceptable"
|
||||
errorLine1=" <clip-path"
|
||||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_wrong.xml"
|
||||
line="26"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconLocation"
|
||||
message="Found bitmap drawable `res/drawable/bg_image.webp` in densityless folder">
|
||||
<location
|
||||
file="src/main/res/drawable/bg_image.webp"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
@@ -19,11 +19,12 @@ val Purple40 = Color(0xFF6650a4)
|
||||
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)
|
||||
val Grey = Color(0xFFFAFAFA)
|
||||
val Pink = Color(0xFFFF99AA)
|
||||
val Purple = Color(0xFF864CBF)
|
||||
val Red = Color(0xFFFF3355)
|
||||
val Red2 = Color(0xFFE21B3C)
|
||||
val Yellow3 = Color(0xFFD89E00)
|
||||
|
@@ -28,7 +28,8 @@ fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
start, end,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,4 +46,4 @@ fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,35 +1,14 @@
|
||||
package dev.adriankuta.kahootquiz.core.designsystem
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -39,19 +18,9 @@ fun KahootQuizTheme(
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
colorScheme = LightColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -15,20 +15,4 @@ val Typography = Typography(
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
)
|
||||
|
4
core/network/lint-baseline.xml
Normal file
4
core/network/lint-baseline.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
|
||||
|
||||
</issues>
|
@@ -4,7 +4,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.adriankuta.kahootquiz.model.data"
|
||||
namespace = "dev.adriankuta.kahootquiz.data"
|
||||
}
|
||||
|
||||
dependencies {
|
5
data/lint-baseline.xml
Normal file
5
data/lint-baseline.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues name="AGP (8.13.0-rc02)" by="lint 8.13.0-rc02" client="gradle" dependencies="false" format="6"
|
||||
type="baseline" variant="all" version="8.13.0-rc02">
|
||||
|
||||
</issues>
|
@@ -1,9 +1,9 @@
|
||||
package dev.adriankuta.kahootquiz.model.data
|
||||
package dev.adriankuta.kahootquiz.data
|
||||
|
||||
import dev.adriankuta.kahootquiz.core.network.retrofit.QuizApi
|
||||
import dev.adriankuta.kahootquiz.data.mappers.toDomainModel
|
||||
import dev.adriankuta.kahootquiz.domain.models.Quiz
|
||||
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
||||
import dev.adriankuta.kahootquiz.model.data.mappers.toDomainModel
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class QuizRepositoryImpl @Inject constructor(
|
@@ -1,11 +1,11 @@
|
||||
package dev.adriankuta.kahootquiz.model.data.di
|
||||
package dev.adriankuta.kahootquiz.data.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dev.adriankuta.kahootquiz.data.QuizRepositoryImpl
|
||||
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
||||
import dev.adriankuta.kahootquiz.model.data.QuizRepositoryImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
@@ -1,4 +1,6 @@
|
||||
package dev.adriankuta.kahootquiz.model.data.mappers
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package dev.adriankuta.kahootquiz.data.mappers
|
||||
|
||||
import dev.adriankuta.kahootquiz.core.network.models.AccessDto
|
||||
import dev.adriankuta.kahootquiz.core.network.models.ChannelDto
|
||||
@@ -151,7 +153,7 @@ private fun QuestionDto.toDomain(): Question = Question(
|
||||
time = time?.milliseconds,
|
||||
points = points,
|
||||
pointsMultiplier = pointsMultiplier,
|
||||
choices = choices?.map { it.toDomain() },
|
||||
choices = choices?.map { it.toDomain() }.orEmpty(),
|
||||
layout = layout,
|
||||
image = image,
|
||||
imageMetadata = imageMetadata?.toDomain(),
|
4
domain/lint-baseline.xml
Normal file
4
domain/lint-baseline.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
|
||||
|
||||
</issues>
|
@@ -6,4 +6,4 @@ data class Access(
|
||||
val groupRead: List<String>?,
|
||||
val folderGroupIds: List<String>?,
|
||||
val features: List<String>?,
|
||||
)
|
||||
)
|
||||
|
@@ -2,4 +2,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
||||
|
||||
// Minimal channel info
|
||||
|
||||
data class Channel(val id: String?)
|
||||
data class Channel(val id: String?)
|
||||
|
@@ -4,4 +4,4 @@ data class Choice(
|
||||
val answer: String?,
|
||||
val correct: Boolean,
|
||||
val languageInfo: LanguageInfo? = null,
|
||||
)
|
||||
)
|
||||
|
@@ -8,4 +8,4 @@ data class ChoiceRange(
|
||||
val step: Int?,
|
||||
val correct: Int?,
|
||||
val tolerance: Int?,
|
||||
)
|
||||
)
|
||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
||||
data class ContentTags(
|
||||
val curriculumCodes: List<String>?,
|
||||
val generatedCurriculumCodes: List<String>?,
|
||||
)
|
||||
)
|
||||
|
@@ -14,4 +14,4 @@ data class CoverMetadata(
|
||||
val extractedColors: List<ExtractedColor>?,
|
||||
val blurhash: String?,
|
||||
val crop: Crop?,
|
||||
)
|
||||
)
|
||||
|
@@ -6,4 +6,4 @@ data class Crop(
|
||||
val origin: Point?,
|
||||
val target: Point?,
|
||||
val circular: Boolean?,
|
||||
)
|
||||
)
|
||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
||||
data class ExtractedColor(
|
||||
val swatch: String?,
|
||||
val rgbHex: String?,
|
||||
)
|
||||
)
|
||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
||||
data class FeaturedListMembership(
|
||||
val list: String?,
|
||||
val addedAt: Long?,
|
||||
)
|
||||
)
|
||||
|
@@ -13,4 +13,4 @@ data class ImageMetadata(
|
||||
val height: Int? = null,
|
||||
val effects: List<String>? = null,
|
||||
val crop: Crop? = null,
|
||||
)
|
||||
)
|
||||
|
@@ -6,4 +6,4 @@ data class LanguageInfo(
|
||||
val language: String?,
|
||||
val lastUpdatedOn: Long?,
|
||||
val readAloudSupported: Boolean?,
|
||||
)
|
||||
)
|
||||
|
@@ -6,4 +6,4 @@ data class LastEdit(
|
||||
val editorUserId: String?,
|
||||
val editorUsername: String?,
|
||||
val editTimestamp: Long?,
|
||||
)
|
||||
)
|
||||
|
@@ -15,4 +15,4 @@ data class MediaItem(
|
||||
val width: Int? = null,
|
||||
val height: Int? = null,
|
||||
val crop: Crop? = null,
|
||||
)
|
||||
)
|
||||
|
@@ -8,4 +8,4 @@ data class Metadata(
|
||||
val featuredListMemberships: List<FeaturedListMembership>?,
|
||||
val lastEdit: LastEdit?,
|
||||
val versionMetadata: VersionMetadata?,
|
||||
)
|
||||
)
|
||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
||||
data class Point(
|
||||
val x: Int?,
|
||||
val y: Int?,
|
||||
)
|
||||
)
|
||||
|
@@ -10,7 +10,7 @@ data class Question(
|
||||
val time: Duration?,
|
||||
val points: Boolean? = null,
|
||||
val pointsMultiplier: Int?,
|
||||
val choices: List<Choice>?,
|
||||
val choices: List<Choice>,
|
||||
val layout: String? = null,
|
||||
val image: String? = null,
|
||||
val imageMetadata: ImageMetadata?,
|
||||
@@ -20,4 +20,4 @@ data class Question(
|
||||
val languageInfo: LanguageInfo? = null,
|
||||
val media: List<MediaItem>? = null,
|
||||
val choiceRange: ChoiceRange? = null,
|
||||
)
|
||||
)
|
||||
|
@@ -32,4 +32,4 @@ data class Quiz(
|
||||
val type: String?,
|
||||
val created: Long?,
|
||||
val modified: Long?,
|
||||
)
|
||||
)
|
||||
|
@@ -6,4 +6,4 @@ data class VersionMetadata(
|
||||
val version: Int?,
|
||||
val created: Long?,
|
||||
val creator: String?,
|
||||
)
|
||||
)
|
||||
|
@@ -6,4 +6,4 @@ data class Video(
|
||||
val endTime: Int?,
|
||||
val service: String?,
|
||||
val fullUrl: String?,
|
||||
)
|
||||
)
|
||||
|
@@ -27,6 +27,6 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
include(":app")
|
||||
include(":core:designsystem")
|
||||
include(":core:network")
|
||||
include(":data")
|
||||
include(":domain")
|
||||
include(":model:data")
|
||||
include(":ui:quiz")
|
||||
|
@@ -1,3 +1,26 @@
|
||||
# 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:
|
||||
|
4
ui/quiz/lint-baseline.xml
Normal file
4
ui/quiz/lint-baseline.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
|
||||
|
||||
</issues>
|
@@ -1,325 +1,158 @@
|
||||
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.fillMaxHeight
|
||||
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.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 dev.adriankuta.kahootquiz.ui.quiz.components.AnswerFeedbackBanner
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.components.Choices
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.components.QuestionContent
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.components.TimerBar
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.components.Toolbar
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
|
||||
|
||||
@Composable
|
||||
fun QuizScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: QuizScreenViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
QuizScreen(
|
||||
uiState = uiState,
|
||||
onSelect = viewModel::onChoiceSelected,
|
||||
onContinue = viewModel::onContinue,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuizScreen(
|
||||
uiState: QuizUiState,
|
||||
uiState: ScreenUiState,
|
||||
onSelect: (Int) -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
Image(
|
||||
painter = painterResource(id = DesignR.drawable.bg_image),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Toolbar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
.padding(8.dp),
|
||||
)
|
||||
QuestionContent(
|
||||
question = uiState.currentQuestion ?: return,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Choices(
|
||||
choices = uiState.currentQuestion.choices ?: emptyList(), // TODO remove empty list
|
||||
answer = uiState.answer,
|
||||
when (uiState) {
|
||||
ScreenUiState.Loading -> QuizScreenLoading()
|
||||
is ScreenUiState.Success -> QuizScreenSuccess(
|
||||
uiState = uiState,
|
||||
onSelect = onSelect,
|
||||
onContinue = onContinue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toolbar(
|
||||
private fun QuizScreenLoading(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "2/24",
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.background(
|
||||
color = Grey,
|
||||
shape = RoundedCornerShape(60.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(
|
||||
color = Grey,
|
||||
shape = RoundedCornerShape(60.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = DesignR.drawable.ic_type),
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.quiz),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuestionContent(
|
||||
question: Question,
|
||||
@Suppress("LongMethod")
|
||||
private fun QuizScreenSuccess(
|
||||
uiState: ScreenUiState.Success,
|
||||
onSelect: (Int) -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = question.image,
|
||||
contentDescription = question.imageMetadata?.altText,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 200.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
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),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Choices(
|
||||
choices: List<Choice>,
|
||||
onSelect: (Int) -> Unit,
|
||||
answer: AnswerUiState?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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) },
|
||||
.height(72.dp),
|
||||
) {
|
||||
Toolbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
currentQuestionIndex = uiState.currentQuestionIndex,
|
||||
totalQuestions = uiState.totalQuestions,
|
||||
)
|
||||
uiState.isAnswerCorrect?.let { isCorrect ->
|
||||
AnswerFeedbackBanner(
|
||||
isCorrect = isCorrect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QuestionContent(
|
||||
question = uiState.currentQuestion,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxHeight(0.5f),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Choices(
|
||||
choices = uiState.currentQuestion.choices,
|
||||
selectedChoiceIndex = uiState.selectedChoiceIndex,
|
||||
onSelect = onSelect,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
// Timer below choices
|
||||
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) {
|
||||
TimerBar(
|
||||
totalSeconds = uiState.timerState.totalTimeSeconds,
|
||||
remainingSeconds = uiState.timerState.remainingTimeSeconds,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onContinue,
|
||||
colors = ButtonDefaults.filledTonalButtonColors().copy(
|
||||
containerColor = Grey,
|
||||
contentColor = Color.Black,
|
||||
),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.continue_text),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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() {
|
||||
@@ -340,10 +173,13 @@ private fun QuizScreenPreview() {
|
||||
imageMetadata = null,
|
||||
)
|
||||
QuizScreen(
|
||||
uiState = QuizUiState(
|
||||
uiState = ScreenUiState.Success(
|
||||
currentQuestion = sampleQuestion,
|
||||
selectedChoiceIndex = null,
|
||||
totalQuestions = 12,
|
||||
),
|
||||
onSelect = {},
|
||||
onContinue = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -368,13 +204,13 @@ private fun QuizScreenRevealedAnswerPreview() {
|
||||
imageMetadata = null,
|
||||
)
|
||||
QuizScreen(
|
||||
uiState = QuizUiState(
|
||||
uiState = ScreenUiState.Success(
|
||||
currentQuestion = sampleQuestion,
|
||||
answer = AnswerUiState(
|
||||
selectedChoiceIndex = 1,
|
||||
),
|
||||
selectedChoiceIndex = 1,
|
||||
totalQuestions = 12,
|
||||
),
|
||||
onSelect = {},
|
||||
onContinue = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -4,44 +4,183 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.adriankuta.kahootquiz.domain.models.Question
|
||||
import dev.adriankuta.kahootquiz.domain.models.Quiz
|
||||
import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.utils.Result
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.utils.asResult
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltViewModel
|
||||
class QuizScreenViewModel @Inject constructor(
|
||||
private val getQuizUseCase: GetQuizUseCase,
|
||||
) : ViewModel() {
|
||||
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) },
|
||||
|
||||
private val quiz: StateFlow<QuizUiState> = flow {
|
||||
emit(QuizUiState.Success(getQuizUseCase()))
|
||||
}
|
||||
.asResult()
|
||||
.map { quizResult ->
|
||||
when (quizResult) {
|
||||
is Result.Error -> QuizUiState.Loading // Todo error handling not implemented on UI
|
||||
Result.Loading -> QuizUiState.Loading
|
||||
is Result.Success -> quizResult.data
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = QuizUiState.Loading,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = QuizUiState(),
|
||||
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
|
||||
private val _remainingTimeSeconds = MutableStateFlow(0)
|
||||
private val _currentQuestionIndex = MutableStateFlow(0)
|
||||
private var timerJob: Job? = null
|
||||
|
||||
init {
|
||||
// Start timer when the first question is displayed (on initial quiz load)
|
||||
viewModelScope.launch {
|
||||
quiz.collect { quizState ->
|
||||
if (quizState is QuizUiState.Success) {
|
||||
// Start only if timer hasn't been started yet and we are on the first question
|
||||
if (timerJob == null && _currentQuestionIndex.value == 0) {
|
||||
val firstQuestionTime =
|
||||
quizState.quiz.questions.getOrNull(0)?.time?.inWholeSeconds?.toInt()
|
||||
startCountdown(firstQuestionTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<ScreenUiState> = screenUiState(
|
||||
quizFlow = quiz,
|
||||
selectedChoiceIndexFlow = _selectedChoiceIndex,
|
||||
remainingTimeSecondsFlow = _remainingTimeSeconds,
|
||||
currentQuestionIndexFlow = _currentQuestionIndex,
|
||||
)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = ScreenUiState.Loading,
|
||||
)
|
||||
|
||||
fun onChoiceSelected(index: Int) {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
_selectedChoiceIndex.value = index
|
||||
}
|
||||
|
||||
fun onContinue() {
|
||||
val quizState = quiz.value
|
||||
if (quizState is QuizUiState.Success) {
|
||||
val total = quizState.quiz.questions.size
|
||||
val current = _currentQuestionIndex.value
|
||||
val nextIndex = current + 1
|
||||
if (nextIndex < total) {
|
||||
_selectedChoiceIndex.value = null
|
||||
_currentQuestionIndex.value = nextIndex
|
||||
val nextQuestionTime =
|
||||
quizState.quiz.questions[nextIndex].time?.inWholeSeconds?.toInt()
|
||||
startCountdown(nextQuestionTime)
|
||||
} else {
|
||||
// Last question reached: stop timer and keep state (could navigate to results in the future)
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
_remainingTimeSeconds.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCountdown(totalSeconds: Int?) {
|
||||
timerJob?.cancel()
|
||||
if (totalSeconds == null || totalSeconds <= 0) {
|
||||
_remainingTimeSeconds.value = 0
|
||||
timerJob = null
|
||||
return
|
||||
}
|
||||
_remainingTimeSeconds.value = totalSeconds
|
||||
timerJob = viewModelScope.launch {
|
||||
var remaining = totalSeconds
|
||||
while (remaining > 0) {
|
||||
delay(1.seconds)
|
||||
remaining -= 1
|
||||
_remainingTimeSeconds.value = remaining
|
||||
}
|
||||
// Time out: reveal answers without a selection
|
||||
if (_selectedChoiceIndex.value == null) {
|
||||
_selectedChoiceIndex.value = -1
|
||||
}
|
||||
timerJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class QuizUiState(
|
||||
val currentQuestion: Question? = null,
|
||||
val answer: AnswerUiState? = null,
|
||||
)
|
||||
private fun screenUiState(
|
||||
quizFlow: StateFlow<QuizUiState>,
|
||||
selectedChoiceIndexFlow: Flow<Int?>,
|
||||
remainingTimeSecondsFlow: Flow<Int>,
|
||||
currentQuestionIndexFlow: Flow<Int>,
|
||||
): Flow<ScreenUiState> = combine(
|
||||
quizFlow,
|
||||
selectedChoiceIndexFlow,
|
||||
remainingTimeSecondsFlow,
|
||||
currentQuestionIndexFlow,
|
||||
) { quizState, selectedChoiceIndex, remainingTimeSeconds, currentQuestionIndex ->
|
||||
when (quizState) {
|
||||
QuizUiState.Loading -> ScreenUiState.Loading
|
||||
is QuizUiState.Success -> {
|
||||
val currentQuestion = quizState.quiz.questions[currentQuestionIndex]
|
||||
val isAnswerCorrect = selectedChoiceIndex?.let { idx ->
|
||||
currentQuestion.choices?.getOrNull(idx)?.correct == true
|
||||
}
|
||||
|
||||
data class AnswerUiState(
|
||||
val selectedChoiceIndex: Int,
|
||||
ScreenUiState.Success(
|
||||
currentQuestion = currentQuestion,
|
||||
selectedChoiceIndex = selectedChoiceIndex,
|
||||
currentQuestionIndex = currentQuestionIndex,
|
||||
totalQuestions = quizState.quiz.questions.size,
|
||||
timerState = TimerState(
|
||||
remainingTimeSeconds = remainingTimeSeconds,
|
||||
totalTimeSeconds = currentQuestion.time?.inWholeSeconds?.toInt() ?: 0,
|
||||
),
|
||||
isAnswerCorrect = isAnswerCorrect,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface QuizUiState {
|
||||
data object Loading : QuizUiState
|
||||
data class Success(
|
||||
val quiz: Quiz,
|
||||
) : QuizUiState
|
||||
}
|
||||
|
||||
sealed interface ScreenUiState {
|
||||
data object Loading : ScreenUiState
|
||||
data class Success(
|
||||
val currentQuestion: Question,
|
||||
val selectedChoiceIndex: Int? = null,
|
||||
val currentQuestionIndex: Int = 0,
|
||||
val totalQuestions: Int = 0,
|
||||
val timerState: TimerState = TimerState(),
|
||||
val isAnswerCorrect: Boolean? = null,
|
||||
) : ScreenUiState
|
||||
}
|
||||
|
||||
data class TimerState(
|
||||
val remainingTimeSeconds: Int = 0,
|
||||
val totalTimeSeconds: Int = 0,
|
||||
)
|
||||
|
@@ -0,0 +1,176 @@
|
||||
@file:OptIn(ExperimentalLayoutApi::class)
|
||||
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.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.domain.models.Choice
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
|
||||
|
||||
@Composable
|
||||
fun Choices(
|
||||
choices: List<Choice>,
|
||||
onSelect: (Int) -> Unit,
|
||||
selectedChoiceIndex: Int?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
EvenGrid(
|
||||
items = choices,
|
||||
columns = 2,
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { choice, index ->
|
||||
ChoiceItem(
|
||||
choice = choice,
|
||||
index = index,
|
||||
selectedChoiceIndex = selectedChoiceIndex,
|
||||
onClick = { onSelect(index) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceItem(
|
||||
choice: Choice,
|
||||
onClick: () -> Unit,
|
||||
index: Int,
|
||||
selectedChoiceIndex: Int?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (selectedChoiceIndex != null) {
|
||||
ChoiceItemRevealed(
|
||||
choice = choice,
|
||||
index = index,
|
||||
isSelected = selectedChoiceIndex == index,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
ChoiceItemDefault(
|
||||
choice = choice,
|
||||
index = index,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceItemDefault(
|
||||
choice: Choice,
|
||||
index: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = when (index) {
|
||||
0 -> Red2
|
||||
1 -> Blue2
|
||||
2 -> Yellow3
|
||||
3 -> Green2
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
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))
|
||||
.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,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset(
|
||||
x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp,
|
||||
y = (-8).dp,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = choice.answer ?: "",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = contrastiveTo(backgroundColor),
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.Green
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.Red
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.R
|
||||
|
||||
@Composable
|
||||
fun AnswerFeedbackBanner(
|
||||
isCorrect: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(10f),
|
||||
shadowElevation = 8.dp,
|
||||
color = if (isCorrect) Green else Red,
|
||||
contentColor = Color.White,
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = stringResource(if (isCorrect) R.string.correct else R.string.wrong),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun <T> EvenGrid(
|
||||
items: List<T>,
|
||||
columns: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
content: @Composable RowScope.(item: T, index: Int) -> Unit,
|
||||
) {
|
||||
val rows = (items.size + columns - 1) / columns // total rows needed
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = verticalArrangement,
|
||||
) {
|
||||
repeat(rows) { rowIndex ->
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
) {
|
||||
repeat(columns) { columnIndex ->
|
||||
val itemIndex = rowIndex * columns + columnIndex
|
||||
if (itemIndex < items.size) {
|
||||
content(items[itemIndex], itemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.HtmlCompat
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString
|
||||
import dev.adriankuta.kahootquiz.domain.models.Question
|
||||
|
||||
@Composable
|
||||
fun QuestionContent(
|
||||
question: Question,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = question.image,
|
||||
contentDescription = question.imageMetadata?.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.clip(shape = RoundedCornerShape(4.dp)),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val questionText = androidx.compose.runtime.remember(question.question) {
|
||||
HtmlCompat.fromHtml(
|
||||
question.question ?: "",
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT,
|
||||
).toAnnotatedString()
|
||||
}
|
||||
Text(
|
||||
text = questionText,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.Purple
|
||||
|
||||
@Composable
|
||||
fun TimerBar(
|
||||
totalSeconds: Int,
|
||||
remainingSeconds: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val target =
|
||||
if (totalSeconds <= 0) 0f else (remainingSeconds.toFloat() / totalSeconds).coerceIn(0f, 1f)
|
||||
val progress: Float by animateFloatAsState(
|
||||
targetValue = target,
|
||||
label = "Timer",
|
||||
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(progress)
|
||||
.background(
|
||||
color = Purple,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "$remainingSeconds",
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.Grey
|
||||
import dev.adriankuta.kahootquiz.ui.quiz.R
|
||||
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
|
||||
|
||||
@Composable
|
||||
fun Toolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
currentQuestionIndex: Int = 0,
|
||||
totalQuestions: Int = 0,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "${currentQuestionIndex + 1}/$totalQuestions",
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.background(
|
||||
color = Grey,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(
|
||||
color = Grey,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = DesignR.drawable.ic_type),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.quiz),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
@@ -12,4 +14,4 @@ fun NavGraphBuilder.quizScreen() {
|
||||
composable<QuizRoute> {
|
||||
QuizScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,16 @@
|
||||
package dev.adriankuta.kahootquiz.ui.quiz.utils
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
|
||||
sealed interface Result<out T> {
|
||||
data class Success<T>(val data: T) : Result<T>
|
||||
data class Error(val exception: Throwable) : Result<Nothing>
|
||||
data object Loading : Result<Nothing>
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
|
||||
.onStart { emit(Result.Loading) }
|
||||
.catch { emit(Result.Error(it)) }
|
@@ -1,4 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="quiz">Quiz</string>
|
||||
<string name="continue_text">Continue</string>
|
||||
<string name="correct">Correct</string>
|
||||
<string name="wrong">Wrong</string>
|
||||
</resources>
|
Reference in New Issue
Block a user