mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-14 17:24:21 +02:00
Compare commits
6 Commits
12638f33d8
...
34b026ec94
Author | SHA1 | Date | |
---|---|---|---|
![]() |
34b026ec94 | ||
![]() |
1b57800641 | ||
![]() |
21ba338d38 | ||
![]() |
59218cc2e1 | ||
![]() |
77a3dd9eeb | ||
![]() |
99f1c49713 |
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>
|
||||||
|
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## What I’m Happy About
|
||||||
|
|
||||||
|
- I created and used convention plugins to reuse modules configuration.
|
||||||
|
- The architecture is clean with multi-modularity and separation of concerns.
|
||||||
|
- I leaned into Kotlin ‘sugar’ where it helps readability and conciseness — I love it.
|
||||||
|
- Configured `Detekt` for static code analysis
|
||||||
|
|
||||||
|
## 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
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>
|
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -20,16 +20,6 @@ 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
|
||||||
|
@@ -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>
|
@@ -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?,
|
||||||
|
@@ -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,15 +1,13 @@
|
|||||||
package dev.adriankuta.kahootquiz.ui.quiz
|
package dev.adriankuta.kahootquiz.ui.quiz
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -20,8 +18,6 @@ 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.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -31,19 +27,18 @@ 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.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.Choices
|
||||||
import dev.adriankuta.kahootquiz.ui.quiz.components.QuestionContent
|
import dev.adriankuta.kahootquiz.ui.quiz.components.QuestionContent
|
||||||
import dev.adriankuta.kahootquiz.ui.quiz.components.TimerBar
|
import dev.adriankuta.kahootquiz.ui.quiz.components.TimerBar
|
||||||
import dev.adriankuta.kahootquiz.ui.quiz.components.Toolbar
|
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(
|
||||||
@@ -62,12 +57,6 @@ private fun QuizScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
Image(
|
|
||||||
painter = painterResource(id = DesignR.drawable.bg_image),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
ScreenUiState.Loading -> QuizScreenLoading()
|
ScreenUiState.Loading -> QuizScreenLoading()
|
||||||
is ScreenUiState.Success -> QuizScreenSuccess(
|
is ScreenUiState.Success -> QuizScreenSuccess(
|
||||||
@@ -75,7 +64,6 @@ private fun QuizScreen(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onContinue = onContinue,
|
onContinue = onContinue,
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,75 +78,60 @@ private fun QuizScreenLoading(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@Suppress("LongMethod")
|
||||||
private fun QuizScreenSuccess(
|
private fun QuizScreenSuccess(
|
||||||
uiState: ScreenUiState.Success,
|
uiState: ScreenUiState.Success,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
onContinue: () -> Unit,
|
onContinue: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
.animateContentSize(),
|
|
||||||
) {
|
) {
|
||||||
toolbar(uiState)
|
Box(
|
||||||
questionContent(uiState)
|
modifier = Modifier
|
||||||
choices(uiState, onSelect)
|
.height(72.dp),
|
||||||
// Timer below choices
|
|
||||||
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) {
|
|
||||||
timer(uiState)
|
|
||||||
} else {
|
|
||||||
continueButton(uiState, onContinue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.toolbar(
|
|
||||||
uiState: ScreenUiState.Success,
|
|
||||||
) {
|
) {
|
||||||
item(key = "toolbar") {
|
|
||||||
Toolbar(
|
Toolbar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.height(72.dp)
|
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
currentQuestionIndex = uiState.currentQuestionIndex,
|
currentQuestionIndex = uiState.currentQuestionIndex,
|
||||||
totalQuestions = uiState.totalQuestions,
|
totalQuestions = uiState.totalQuestions,
|
||||||
)
|
)
|
||||||
|
uiState.isAnswerCorrect?.let { isCorrect ->
|
||||||
|
AnswerFeedbackBanner(
|
||||||
|
isCorrect = isCorrect,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.questionContent(
|
|
||||||
uiState: ScreenUiState.Success,
|
|
||||||
) {
|
|
||||||
if (uiState.currentQuestion != null) {
|
|
||||||
item(key = "question_${uiState.currentQuestionIndex}") {
|
|
||||||
QuestionContent(
|
QuestionContent(
|
||||||
question = uiState.currentQuestion,
|
question = uiState.currentQuestion,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = 8.dp)
|
||||||
.animateItem(),
|
.fillMaxHeight(0.5f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.timer(uiState: ScreenUiState.Success) {
|
Choices(
|
||||||
item(key = "timer_${uiState.currentQuestionIndex}") {
|
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(
|
TimerBar(
|
||||||
totalSeconds = uiState.timerState.totalTimeSeconds,
|
totalSeconds = uiState.timerState.totalTimeSeconds,
|
||||||
remainingSeconds = uiState.timerState.remainingTimeSeconds,
|
remainingSeconds = uiState.timerState.remainingTimeSeconds,
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.continueButton(
|
|
||||||
uiState: ScreenUiState.Success,
|
|
||||||
onContinue: () -> Unit,
|
|
||||||
) {
|
|
||||||
item(key = "continue_${uiState.currentQuestionIndex}") {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
@@ -178,23 +151,6 @@ private fun LazyListScope.continueButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.choices(
|
|
||||||
uiState: ScreenUiState.Success,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
uiState.currentQuestion?.choices?.let { choicesList ->
|
|
||||||
if (choicesList.isNotEmpty()) {
|
|
||||||
item(key = "choices_${uiState.currentQuestionIndex}") {
|
|
||||||
Choices(
|
|
||||||
choices = choicesList,
|
|
||||||
selectedChoiceIndex = uiState.selectedChoiceIndex,
|
|
||||||
onSelect = onSelect,
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
@@ -6,16 +6,21 @@ 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.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.Job
|
||||||
import kotlinx.coroutines.delay
|
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.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flow
|
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 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(
|
||||||
@@ -24,6 +29,14 @@ class QuizScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val quiz: StateFlow<QuizUiState> = flow {
|
private val quiz: StateFlow<QuizUiState> = flow {
|
||||||
emit(QuizUiState.Success(getQuizUseCase()))
|
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(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -51,31 +64,12 @@ class QuizScreenViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<ScreenUiState> = combine(
|
val uiState: StateFlow<ScreenUiState> = screenUiState(
|
||||||
quiz,
|
quizFlow = quiz,
|
||||||
_selectedChoiceIndex,
|
selectedChoiceIndexFlow = _selectedChoiceIndex,
|
||||||
_remainingTimeSeconds,
|
remainingTimeSecondsFlow = _remainingTimeSeconds,
|
||||||
_currentQuestionIndex,
|
currentQuestionIndexFlow = _currentQuestionIndex,
|
||||||
) { quizState, selectedChoiceIndex, remainingTimeSeconds, currentQuestionIndex ->
|
|
||||||
when (quizState) {
|
|
||||||
QuizUiState.Loading -> ScreenUiState.Loading
|
|
||||||
is QuizUiState.Success -> {
|
|
||||||
val currentQuestion = quizState.quiz.questions.getOrNull(currentQuestionIndex)
|
|
||||||
|
|
||||||
ScreenUiState.Success(
|
|
||||||
currentQuestion = currentQuestion,
|
|
||||||
selectedChoiceIndex = selectedChoiceIndex,
|
|
||||||
currentQuestionIndex = currentQuestionIndex,
|
|
||||||
totalQuestions = quizState.quiz.questions.size,
|
|
||||||
timerState = TimerState(
|
|
||||||
remainingTimeSeconds = remainingTimeSeconds,
|
|
||||||
totalTimeSeconds = currentQuestion?.time?.inWholeSeconds?.toInt() ?: 0,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
@@ -120,7 +114,7 @@ class QuizScreenViewModel @Inject constructor(
|
|||||||
timerJob = viewModelScope.launch {
|
timerJob = viewModelScope.launch {
|
||||||
var remaining = totalSeconds
|
var remaining = totalSeconds
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
delay(1000)
|
delay(1.seconds)
|
||||||
remaining -= 1
|
remaining -= 1
|
||||||
_remainingTimeSeconds.value = remaining
|
_remainingTimeSeconds.value = remaining
|
||||||
}
|
}
|
||||||
@@ -133,6 +127,40 @@ class QuizScreenViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
sealed interface QuizUiState {
|
||||||
data object Loading : QuizUiState
|
data object Loading : QuizUiState
|
||||||
data class Success(
|
data class Success(
|
||||||
@@ -143,11 +171,12 @@ sealed interface QuizUiState {
|
|||||||
sealed interface ScreenUiState {
|
sealed interface ScreenUiState {
|
||||||
data object Loading : ScreenUiState
|
data object Loading : ScreenUiState
|
||||||
data class Success(
|
data class Success(
|
||||||
val currentQuestion: Question? = null,
|
val currentQuestion: Question,
|
||||||
val selectedChoiceIndex: Int? = null,
|
val selectedChoiceIndex: Int? = null,
|
||||||
val currentQuestionIndex: Int = 0,
|
val currentQuestionIndex: Int = 0,
|
||||||
val totalQuestions: Int = 0,
|
val totalQuestions: Int = 0,
|
||||||
val timerState: TimerState = TimerState(),
|
val timerState: TimerState = TimerState(),
|
||||||
|
val isAnswerCorrect: Boolean? = null,
|
||||||
) : ScreenUiState
|
) : ScreenUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(ExperimentalLayoutApi::class)
|
||||||
|
|
||||||
package dev.adriankuta.kahootquiz.ui.quiz.components
|
package dev.adriankuta.kahootquiz.ui.quiz.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
@@ -5,8 +7,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -37,23 +39,24 @@ fun Choices(
|
|||||||
selectedChoiceIndex: Int?,
|
selectedChoiceIndex: Int?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
EvenGrid(
|
||||||
maxItemsInEachRow = 2,
|
items = choices,
|
||||||
|
columns = 2,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
choices.forEachIndexed { index, choice ->
|
) { choice, index ->
|
||||||
ChoiceItem(
|
ChoiceItem(
|
||||||
choice = choice,
|
choice = choice,
|
||||||
index = index,
|
index = index,
|
||||||
selectedChoiceIndex = selectedChoiceIndex,
|
selectedChoiceIndex = selectedChoiceIndex,
|
||||||
onClick = { onSelect(index) },
|
onClick = { onSelect(index) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChoiceItem(
|
private fun ChoiceItem(
|
||||||
@@ -104,7 +107,6 @@ private fun ChoiceItemDefault(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
|
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
|
||||||
.height(100.dp)
|
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
),
|
),
|
||||||
@@ -152,8 +154,7 @@ private fun ChoiceItemRevealed(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
|
.background(backgroundColor, shape = RoundedCornerShape(4.dp)),
|
||||||
.height(100.dp),
|
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(icon),
|
painter = painterResource(icon),
|
||||||
|
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -5,11 +5,11 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -32,10 +32,10 @@ fun QuestionContent(
|
|||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = question.image,
|
model = question.image,
|
||||||
contentDescription = question.imageMetadata?.altText,
|
contentDescription = question.imageMetadata?.altText,
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.heightIn(min = 200.dp)
|
.align(Alignment.CenterHorizontally)
|
||||||
.clip(shape = RoundedCornerShape(4.dp)),
|
.clip(shape = RoundedCornerShape(4.dp)),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
@@ -33,7 +33,7 @@ fun TimerBar(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth(progress.coerceIn(0f, 1f))
|
.fillMaxWidth(progress)
|
||||||
.background(
|
.background(
|
||||||
color = Purple,
|
color = Purple,
|
||||||
shape = RoundedCornerShape(percent = 50),
|
shape = RoundedCornerShape(percent = 50),
|
||||||
|
@@ -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
|
||||||
|
@@ -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)) }
|
@@ -2,4 +2,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="quiz">Quiz</string>
|
<string name="quiz">Quiz</string>
|
||||||
<string name="continue_text">Continue</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