mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-15 01:24:23 +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" />
|
||||||
<option value="$PROJECT_DIR$/core/designsystem" />
|
<option value="$PROJECT_DIR$/core/designsystem" />
|
||||||
<option value="$PROJECT_DIR$/core/network" />
|
<option value="$PROJECT_DIR$/core/network" />
|
||||||
|
<option value="$PROJECT_DIR$/data" />
|
||||||
<option value="$PROJECT_DIR$/domain" />
|
<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" />
|
||||||
<option value="$PROJECT_DIR$/ui/quiz" />
|
<option value="$PROJECT_DIR$/ui/quiz" />
|
||||||
</set>
|
</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 {
|
dependencies {
|
||||||
implementation(projects.core.designsystem)
|
implementation(projects.core.designsystem)
|
||||||
implementation(projects.domain)
|
implementation(projects.domain)
|
||||||
implementation(projects.model.data)
|
implementation(projects.data)
|
||||||
implementation(projects.ui.quiz)
|
implementation(projects.ui.quiz)
|
||||||
|
|
||||||
implementation(libs.androidx.activity.compose)
|
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
|
package dev.adriankuta.kahootquiz
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun KahootQuizApp(
|
fun KahootQuizApp(
|
||||||
@@ -15,6 +20,12 @@ fun KahootQuizApp(
|
|||||||
contentWindowInsets = WindowInsets.safeDrawing,
|
contentWindowInsets = WindowInsets.safeDrawing,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) { paddingValues ->
|
) { 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()
|
quizScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,4 +13,4 @@ class ExampleUnitTest {
|
|||||||
fun addition_isCorrect() {
|
fun addition_isCorrect() {
|
||||||
assertEquals(4, 2 + 2)
|
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 PurpleGrey40 = Color(0xFF625b71)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
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 Blue2 = Color(0xFF1368CE)
|
||||||
val Yellow3 = Color(0xFFD89E00)
|
|
||||||
val Green = Color(0xFF66BF39)
|
val Green = Color(0xFF66BF39)
|
||||||
val Green2 = Color(0xFF26890C)
|
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,
|
fontWeight = FontWeight.Bold,
|
||||||
fontStyle = FontStyle.Italic,
|
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
|
package dev.adriankuta.kahootquiz.core.designsystem
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
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(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Purple40,
|
||||||
secondary = PurpleGrey40,
|
secondary = PurpleGrey40,
|
||||||
tertiary = Pink40,
|
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
|
@Composable
|
||||||
@@ -39,19 +18,9 @@ fun KahootQuizTheme(
|
|||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
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(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = LightColorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -15,20 +15,4 @@ val Typography = Typography(
|
|||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.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 {
|
android {
|
||||||
namespace = "dev.adriankuta.kahootquiz.model.data"
|
namespace = "dev.adriankuta.kahootquiz.data"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.core.network.retrofit.QuizApi
|
||||||
|
import dev.adriankuta.kahootquiz.data.mappers.toDomainModel
|
||||||
import dev.adriankuta.kahootquiz.domain.models.Quiz
|
import dev.adriankuta.kahootquiz.domain.models.Quiz
|
||||||
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
||||||
import dev.adriankuta.kahootquiz.model.data.mappers.toDomainModel
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class QuizRepositoryImpl @Inject constructor(
|
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.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dev.adriankuta.kahootquiz.data.QuizRepositoryImpl
|
||||||
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
|
||||||
import dev.adriankuta.kahootquiz.model.data.QuizRepositoryImpl
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@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.AccessDto
|
||||||
import dev.adriankuta.kahootquiz.core.network.models.ChannelDto
|
import dev.adriankuta.kahootquiz.core.network.models.ChannelDto
|
||||||
@@ -151,7 +153,7 @@ private fun QuestionDto.toDomain(): Question = Question(
|
|||||||
time = time?.milliseconds,
|
time = time?.milliseconds,
|
||||||
points = points,
|
points = points,
|
||||||
pointsMultiplier = pointsMultiplier,
|
pointsMultiplier = pointsMultiplier,
|
||||||
choices = choices?.map { it.toDomain() },
|
choices = choices?.map { it.toDomain() }.orEmpty(),
|
||||||
layout = layout,
|
layout = layout,
|
||||||
image = image,
|
image = image,
|
||||||
imageMetadata = imageMetadata?.toDomain(),
|
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 groupRead: List<String>?,
|
||||||
val folderGroupIds: List<String>?,
|
val folderGroupIds: List<String>?,
|
||||||
val features: List<String>?,
|
val features: List<String>?,
|
||||||
)
|
)
|
||||||
|
@@ -2,4 +2,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
|||||||
|
|
||||||
// Minimal channel info
|
// 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 answer: String?,
|
||||||
val correct: Boolean,
|
val correct: Boolean,
|
||||||
val languageInfo: LanguageInfo? = null,
|
val languageInfo: LanguageInfo? = null,
|
||||||
)
|
)
|
||||||
|
@@ -8,4 +8,4 @@ data class ChoiceRange(
|
|||||||
val step: Int?,
|
val step: Int?,
|
||||||
val correct: Int?,
|
val correct: Int?,
|
||||||
val tolerance: Int?,
|
val tolerance: Int?,
|
||||||
)
|
)
|
||||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
|||||||
data class ContentTags(
|
data class ContentTags(
|
||||||
val curriculumCodes: List<String>?,
|
val curriculumCodes: List<String>?,
|
||||||
val generatedCurriculumCodes: List<String>?,
|
val generatedCurriculumCodes: List<String>?,
|
||||||
)
|
)
|
||||||
|
@@ -14,4 +14,4 @@ data class CoverMetadata(
|
|||||||
val extractedColors: List<ExtractedColor>?,
|
val extractedColors: List<ExtractedColor>?,
|
||||||
val blurhash: String?,
|
val blurhash: String?,
|
||||||
val crop: Crop?,
|
val crop: Crop?,
|
||||||
)
|
)
|
||||||
|
@@ -6,4 +6,4 @@ data class Crop(
|
|||||||
val origin: Point?,
|
val origin: Point?,
|
||||||
val target: Point?,
|
val target: Point?,
|
||||||
val circular: Boolean?,
|
val circular: Boolean?,
|
||||||
)
|
)
|
||||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
|||||||
data class ExtractedColor(
|
data class ExtractedColor(
|
||||||
val swatch: String?,
|
val swatch: String?,
|
||||||
val rgbHex: String?,
|
val rgbHex: String?,
|
||||||
)
|
)
|
||||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
|||||||
data class FeaturedListMembership(
|
data class FeaturedListMembership(
|
||||||
val list: String?,
|
val list: String?,
|
||||||
val addedAt: Long?,
|
val addedAt: Long?,
|
||||||
)
|
)
|
||||||
|
@@ -13,4 +13,4 @@ data class ImageMetadata(
|
|||||||
val height: Int? = null,
|
val height: Int? = null,
|
||||||
val effects: List<String>? = null,
|
val effects: List<String>? = null,
|
||||||
val crop: Crop? = null,
|
val crop: Crop? = null,
|
||||||
)
|
)
|
||||||
|
@@ -6,4 +6,4 @@ data class LanguageInfo(
|
|||||||
val language: String?,
|
val language: String?,
|
||||||
val lastUpdatedOn: Long?,
|
val lastUpdatedOn: Long?,
|
||||||
val readAloudSupported: Boolean?,
|
val readAloudSupported: Boolean?,
|
||||||
)
|
)
|
||||||
|
@@ -6,4 +6,4 @@ data class LastEdit(
|
|||||||
val editorUserId: String?,
|
val editorUserId: String?,
|
||||||
val editorUsername: String?,
|
val editorUsername: String?,
|
||||||
val editTimestamp: Long?,
|
val editTimestamp: Long?,
|
||||||
)
|
)
|
||||||
|
@@ -15,4 +15,4 @@ data class MediaItem(
|
|||||||
val width: Int? = null,
|
val width: Int? = null,
|
||||||
val height: Int? = null,
|
val height: Int? = null,
|
||||||
val crop: Crop? = null,
|
val crop: Crop? = null,
|
||||||
)
|
)
|
||||||
|
@@ -8,4 +8,4 @@ data class Metadata(
|
|||||||
val featuredListMemberships: List<FeaturedListMembership>?,
|
val featuredListMemberships: List<FeaturedListMembership>?,
|
||||||
val lastEdit: LastEdit?,
|
val lastEdit: LastEdit?,
|
||||||
val versionMetadata: VersionMetadata?,
|
val versionMetadata: VersionMetadata?,
|
||||||
)
|
)
|
||||||
|
@@ -5,4 +5,4 @@ package dev.adriankuta.kahootquiz.domain.models
|
|||||||
data class Point(
|
data class Point(
|
||||||
val x: Int?,
|
val x: Int?,
|
||||||
val y: Int?,
|
val y: Int?,
|
||||||
)
|
)
|
||||||
|
@@ -10,7 +10,7 @@ data class Question(
|
|||||||
val time: Duration?,
|
val time: Duration?,
|
||||||
val points: Boolean? = null,
|
val points: Boolean? = null,
|
||||||
val pointsMultiplier: Int?,
|
val pointsMultiplier: Int?,
|
||||||
val choices: List<Choice>?,
|
val choices: List<Choice>,
|
||||||
val layout: String? = null,
|
val layout: String? = null,
|
||||||
val image: String? = null,
|
val image: String? = null,
|
||||||
val imageMetadata: ImageMetadata?,
|
val imageMetadata: ImageMetadata?,
|
||||||
@@ -20,4 +20,4 @@ data class Question(
|
|||||||
val languageInfo: LanguageInfo? = null,
|
val languageInfo: LanguageInfo? = null,
|
||||||
val media: List<MediaItem>? = null,
|
val media: List<MediaItem>? = null,
|
||||||
val choiceRange: ChoiceRange? = null,
|
val choiceRange: ChoiceRange? = null,
|
||||||
)
|
)
|
||||||
|
@@ -32,4 +32,4 @@ data class Quiz(
|
|||||||
val type: String?,
|
val type: String?,
|
||||||
val created: Long?,
|
val created: Long?,
|
||||||
val modified: Long?,
|
val modified: Long?,
|
||||||
)
|
)
|
||||||
|
@@ -6,4 +6,4 @@ data class VersionMetadata(
|
|||||||
val version: Int?,
|
val version: Int?,
|
||||||
val created: Long?,
|
val created: Long?,
|
||||||
val creator: String?,
|
val creator: String?,
|
||||||
)
|
)
|
||||||
|
@@ -6,4 +6,4 @@ data class Video(
|
|||||||
val endTime: Int?,
|
val endTime: Int?,
|
||||||
val service: String?,
|
val service: String?,
|
||||||
val fullUrl: String?,
|
val fullUrl: String?,
|
||||||
)
|
)
|
||||||
|
@@ -27,6 +27,6 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
|||||||
include(":app")
|
include(":app")
|
||||||
include(":core:designsystem")
|
include(":core:designsystem")
|
||||||
include(":core:network")
|
include(":core:network")
|
||||||
|
include(":data")
|
||||||
include(":domain")
|
include(":domain")
|
||||||
include(":model:data")
|
|
||||||
include(":ui:quiz")
|
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
|
# Deviations from defaults
|
||||||
formatting:
|
formatting:
|
||||||
TrailingCommaOnCallSite:
|
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
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.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.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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.text.HtmlCompat
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Blue2
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Green
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Green2
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Grey
|
import dev.adriankuta.kahootquiz.core.designsystem.Grey
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
|
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Pink
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Red
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Red2
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo
|
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString
|
|
||||||
import dev.adriankuta.kahootquiz.domain.models.Choice
|
import dev.adriankuta.kahootquiz.domain.models.Choice
|
||||||
import dev.adriankuta.kahootquiz.domain.models.Question
|
import dev.adriankuta.kahootquiz.domain.models.Question
|
||||||
|
import 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 kotlin.time.Duration.Companion.seconds
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QuizScreen(
|
fun QuizScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: QuizScreenViewModel = hiltViewModel(),
|
viewModel: QuizScreenViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
QuizScreen(
|
QuizScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onSelect = viewModel::onChoiceSelected,
|
onSelect = viewModel::onChoiceSelected,
|
||||||
|
onContinue = viewModel::onContinue,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QuizScreen(
|
private fun QuizScreen(
|
||||||
uiState: QuizUiState,
|
uiState: ScreenUiState,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
|
onContinue: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
Image(
|
when (uiState) {
|
||||||
painter = painterResource(id = DesignR.drawable.bg_image),
|
ScreenUiState.Loading -> QuizScreenLoading()
|
||||||
contentDescription = null,
|
is ScreenUiState.Success -> QuizScreenSuccess(
|
||||||
contentScale = ContentScale.Crop,
|
uiState = uiState,
|
||||||
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,
|
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onContinue = onContinue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Toolbar(
|
private fun QuizScreenLoading(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
CircularProgressIndicator(
|
||||||
modifier = modifier,
|
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
|
@Composable
|
||||||
private fun QuestionContent(
|
@Suppress("LongMethod")
|
||||||
question: Question,
|
private fun QuizScreenSuccess(
|
||||||
|
uiState: ScreenUiState.Success,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onContinue: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
Box(
|
||||||
model = question.image,
|
|
||||||
contentDescription = question.imageMetadata?.altText,
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.height(72.dp),
|
||||||
.heightIn(min = 200.dp),
|
) {
|
||||||
)
|
Toolbar(
|
||||||
Spacer(Modifier.height(16.dp))
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxSize()
|
||||||
text = HtmlCompat.fromHtml(
|
.padding(8.dp),
|
||||||
question.question ?: "",
|
currentQuestionIndex = uiState.currentQuestionIndex,
|
||||||
HtmlCompat.FROM_HTML_MODE_COMPACT,
|
totalQuestions = uiState.totalQuestions,
|
||||||
).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) },
|
|
||||||
)
|
)
|
||||||
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun QuizScreenPreview() {
|
private fun QuizScreenPreview() {
|
||||||
@@ -340,10 +173,13 @@ private fun QuizScreenPreview() {
|
|||||||
imageMetadata = null,
|
imageMetadata = null,
|
||||||
)
|
)
|
||||||
QuizScreen(
|
QuizScreen(
|
||||||
uiState = QuizUiState(
|
uiState = ScreenUiState.Success(
|
||||||
currentQuestion = sampleQuestion,
|
currentQuestion = sampleQuestion,
|
||||||
|
selectedChoiceIndex = null,
|
||||||
|
totalQuestions = 12,
|
||||||
),
|
),
|
||||||
onSelect = {},
|
onSelect = {},
|
||||||
|
onContinue = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,13 +204,13 @@ private fun QuizScreenRevealedAnswerPreview() {
|
|||||||
imageMetadata = null,
|
imageMetadata = null,
|
||||||
)
|
)
|
||||||
QuizScreen(
|
QuizScreen(
|
||||||
uiState = QuizUiState(
|
uiState = ScreenUiState.Success(
|
||||||
currentQuestion = sampleQuestion,
|
currentQuestion = sampleQuestion,
|
||||||
answer = AnswerUiState(
|
selectedChoiceIndex = 1,
|
||||||
selectedChoiceIndex = 1,
|
totalQuestions = 12,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onSelect = {},
|
onSelect = {},
|
||||||
|
onContinue = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,44 +4,183 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.adriankuta.kahootquiz.domain.models.Question
|
import dev.adriankuta.kahootquiz.domain.models.Question
|
||||||
|
import dev.adriankuta.kahootquiz.domain.models.Quiz
|
||||||
import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class QuizScreenViewModel @Inject constructor(
|
class QuizScreenViewModel @Inject constructor(
|
||||||
private val getQuizUseCase: GetQuizUseCase,
|
private val getQuizUseCase: GetQuizUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
|
|
||||||
val uiState: StateFlow<QuizUiState> = combine(
|
private val quiz: StateFlow<QuizUiState> = flow {
|
||||||
suspend { getQuizUseCase() }.asFlow(),
|
emit(QuizUiState.Success(getQuizUseCase()))
|
||||||
_selectedChoiceIndex,
|
}
|
||||||
) { quiz, selectedChoiceIndex ->
|
.asResult()
|
||||||
QuizUiState(
|
.map { quizResult ->
|
||||||
currentQuestion = quiz.questions.first(),
|
when (quizResult) {
|
||||||
answer = selectedChoiceIndex?.let { AnswerUiState(it) },
|
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(
|
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
|
||||||
scope = viewModelScope,
|
private val _remainingTimeSeconds = MutableStateFlow(0)
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
private val _currentQuestionIndex = MutableStateFlow(0)
|
||||||
initialValue = QuizUiState(),
|
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) {
|
fun onChoiceSelected(index: Int) {
|
||||||
|
timerJob?.cancel()
|
||||||
|
timerJob = null
|
||||||
_selectedChoiceIndex.value = index
|
_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(
|
private fun screenUiState(
|
||||||
val currentQuestion: Question? = null,
|
quizFlow: StateFlow<QuizUiState>,
|
||||||
val answer: AnswerUiState? = null,
|
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(
|
ScreenUiState.Success(
|
||||||
val selectedChoiceIndex: Int,
|
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
|
package dev.adriankuta.kahootquiz.ui.quiz.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation.NavGraphBuilder
|
||||||
@@ -12,4 +14,4 @@ fun NavGraphBuilder.quizScreen() {
|
|||||||
composable<QuizRoute> {
|
composable<QuizRoute> {
|
||||||
QuizScreen()
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="quiz">Quiz</string>
|
<string name="quiz">Quiz</string>
|
||||||
|
<string name="continue_text">Continue</string>
|
||||||
|
<string name="correct">Correct</string>
|
||||||
|
<string name="wrong">Wrong</string>
|
||||||
</resources>
|
</resources>
|
Reference in New Issue
Block a user