Compare commits

...

6 Commits

Author SHA1 Message Date
Adrian Kuta
34b026ec94 Refactor: Clean up QuizScreen and adjust image scaling
This commit includes several refactoring changes and a minor UI adjustment:

- **UI Layer (`ui:quiz` module):**
    - In `QuizScreen.kt`:
        - Removed the unused `timer` extension function for `LazyListScope`.
        - Applied `@Suppress("LongMethod")` to `QuizScreenSuccess` composable.
        - Simplified the `modifier` usage within the `Box` in `QuizScreenSuccess`.
    - In `components/QuestionContent.kt`:
        - Changed `ContentScale` for `AsyncImage` from `FillWidth` to `Fit`.
        - Aligned the `AsyncImage` to `Alignment.CenterHorizontally`.
- **Data Layer (`data` module):**
    - In `QuizRepositoryImpl.kt`:
        - Reordered import statements.

Note: The commit also includes changes to a binary file `App.apk`, which are not detailed here.
2025-09-05 00:11:11 +02:00
Adrian Kuta
1b57800641 refactor: Improve QuizScreen layout and choice presentation
This commit refactors the `QuizScreen` to use a `Column` instead of a `LazyColumn` for its main layout, improving the positioning and sizing of elements. It also introduces an `EvenGrid` composable for displaying choices, ensuring they are evenly distributed.

Key changes:

- **QuizScreen.kt:**
    - Changed the main layout from `LazyColumn` to `Column`.
    - Toolbar, QuestionContent, Choices, Timer, and ContinueButton are now direct children of the `Column`.
    - `QuestionContent` now uses `fillMaxHeight(0.5f)` to take up half the available height.
    - `Choices` composable now uses `weight(1f)` to fill remaining space.
    - Removed `animateContentSize` and `animateItem` modifiers.
    - Refactored how individual UI sections (toolbar, question, choices, timer, continue button) are structured within the main `Column`.
- **components/EvenGrid.kt:**
    - Added a new reusable `EvenGrid` composable that arranges items into a grid with a specified number of columns, ensuring even distribution.
- **components/Choices.kt:**
    - Replaced `FlowRow` with the new `EvenGrid` composable to display choices.
    - `ChoiceItem` now uses `fillMaxHeight()` within the `EvenGrid` cell.
    - Removed explicit height setting for `ChoiceItem`'s `Box`.
- **components/QuestionContent.kt:**
    - `AsyncImage` within `QuestionContent` now uses `weight(1f)` instead of `heightIn(min = 200.dp)`.
- **components/TimerBar.kt:**
    - Removed `coerceIn(0f, 1f)` for `progress` in `fillMaxWidth` as progress should already be within this range.
- **QuizScreenViewModel.kt & domain/models/Question.kt:**
    - Made `Question.choices` non-nullable (`List<Choice>`) and updated the mapper and ViewModel to reflect this. This simplifies null checks for `currentQuestion.choices`.
    - Accessing `quizState.quiz.questions[currentQuestionIndex]` directly instead of `getOrNull` as the index should always be valid.
- **data/mappers/QuizMapper.kt:**
    - Updated `QuestionDto.toDomain()` to return `orEmpty()` for choices, ensuring a non-null list.
2025-09-05 00:06:42 +02:00
Adrian Kuta
21ba338d38 refactor: Relocate data module and add README
This commit moves the `model:data` module to a top-level `data` module.
Additionally, a `README.md` file has been added to the project root, providing an overview of the project, architecture, build instructions, current limitations, and suggested improvements.

Key changes:
- Renamed Gradle module `model:data` to `data`.
- Updated `settings.gradle.kts` to reflect the new module path.
- Updated `app/build.gradle.kts` to depend on `projects.data` instead of `projects.model.data`.
- All source files, including `QuizRepositoryImpl`, `QuizMapper`, and `RepositoryModule`, were moved from `model/data/src` to `data/src`.
- Configuration files (`detekt.yml`, `lint-baseline.xml`, `build.gradle.kts`) were moved from `model/data` to `data`.
- Added a new `README.md` file at the project root.
2025-09-04 23:02:45 +02:00
Adrian Kuta
59218cc2e1 feat: Refactor ViewModel state production and add lint baselines
This commit refactors how `ScreenUiState` is produced in `QuizScreenViewModel` by extracting the logic into a private `screenUiState` function that combines various flows. It also introduces `Result` sealed interface and `asResult()` Flow extension for handling asynchronous operations. Additionally, lint baseline files have been added to all modules and a Detekt configuration file for the `ui:quiz` module.

Key changes:

- **ViewModel Refactoring (`ui:quiz` module):**
    - Introduced `Result.kt` with a sealed interface for `Success`, `Error`, and `Loading` states, along with an `asResult()` extension function for `Flow` to wrap emissions in `Result`.
    - In `QuizScreenViewModel.kt`:
        - The `quiz` state flow now uses `asResult()` and maps the `Result` to `QuizUiState`, handling potential error states by defaulting to `QuizUiState.Loading` (actual error UI handling is noted as a TODO).
        - The logic for combining flows to produce `ScreenUiState` has been extracted into a new private function `screenUiState()`. This function takes the necessary flows as parameters and returns a `Flow<ScreenUiState>`.
        - The `uiState` flow now uses this new `screenUiState()` function for its production.
- **Lint and Detekt Configuration:**
    - Added `lint-baseline.xml` files to the following modules to establish a baseline for lint warnings:
        - `app`
        - `core/designsystem`
        - `core/network`
        - `domain`
        - `model/data`
        - `ui/quiz`
    - Added `config/detekt/detekt.yml` to the `ui:quiz` module to configure Detekt rules, including exceptions for Jetpack Compose naming conventions, complexity rules for Composable functions, and style guidelines for magic numbers and unused private members. It also enables trailing commas for call sites and declaration sites.
- **Binary File:**
    - Added `App.apk`.
2025-09-04 22:41:06 +02:00
Adrian Kuta
77a3dd9eeb Refactor: Move background image to main App composable and cleanup
This commit refactors the placement of the background image, moving it from `QuizScreen` to the main `KahootQuizApp` composable. This ensures the background is consistently applied across the app.

Additionally, this commit includes:
- Removal of unused Detekt configuration file (`ui/quiz/config/detekt/detekt.yml`).
- Minor code cleanup:
    - Removed commented-out code in `Theme.kt` and `Type.kt`.
    - Removed trailing blank lines in various domain model files.
    - Added `@file:Suppress("TooManyFunctions")` to `QuizMapper.kt`.
    - Added `@file:Suppress("MatchingDeclarationName")` to `QuizNavigation.kt`.
    - Used `1.seconds` instead of `1000` (Long) for delay in `QuizScreenViewModel`.
2025-09-04 22:17:57 +02:00
Adrian Kuta
99f1c49713 feat: Implement Answer Feedback Banner
This commit introduces an `AnswerFeedbackBanner` composable that displays whether a selected answer is correct or wrong. It's integrated into the `Toolbar` section of the `QuizScreen`.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - Created `AnswerFeedbackBanner.kt` composable:
        - Displays "Correct" or "Wrong" text with a green or red background respectively.
        - Uses `Surface` for elevation and `zIndex` to appear above other elements.
    - In `QuizScreen.kt`:
        - Modified the `toolbar` item to include a `Box` that layers the `Toolbar` and the new `AnswerFeedbackBanner`.
        - The `AnswerFeedbackBanner` is shown when `uiState.isAnswerCorrect` is not null.
    - In `QuizScreenViewModel.kt`:
        - Added `isAnswerCorrect: Boolean?` to `ScreenUiState.Success`.
        - Calculated `isAnswerCorrect` based on the selected choice and the correct answer for the current question.
- **Resources (`ui:quiz` module):**
    - Added new string resources for "Correct" and "Wrong" in `strings.xml`.
2025-09-04 22:04:40 +02:00
52 changed files with 769 additions and 219 deletions

3
.idea/gradle.xml generated
View File

@@ -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>

BIN
App.apk Normal file

Binary file not shown.

104
README.md Normal file
View 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 Im 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 Googles
GenKit framework — that prepares content for that app. It leverages multiple models, vector stores,
and embeddings to orchestrate cooperative behaviors.
If youre 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.

View File

@@ -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
View 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=&quot;85.84757&quot;"
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=&quot;92.4963&quot;"
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=&quot;42.9492&quot;"
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=&quot;49.59793&quot;"
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=&quot;0.0&quot; />"
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=&quot;1.0&quot; />"
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=&quot;nonZero&quot;"
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=" &lt;color name=&quot;purple_200&quot;>#FFBB86FC&lt;/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=" &lt;color name=&quot;purple_500&quot;>#FF6200EE&lt;/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=" &lt;color name=&quot;purple_700&quot;>#FF3700B3&lt;/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=" &lt;color name=&quot;teal_200&quot;>#FF03DAC5&lt;/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=" &lt;color name=&quot;teal_700&quot;>#FF018786&lt;/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=" &lt;color name=&quot;black&quot;>#FF000000&lt;/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=" &lt;color name=&quot;white&quot;>#FFFFFFFF&lt;/color>"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="9"
column="12"/>
</issue>
</issues>

View File

@@ -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))
} }
} }

View 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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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=&quot;evenOdd&quot;"
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 &lt; 24; check generated icon to make sure it looks acceptable"
errorLine1=" &lt;clip-path android:pathData=&quot;M5.853,5.721h28.284v28.284h-28.284z&quot; />"
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 &lt; 24; check generated icon to make sure it looks acceptable"
errorLine1=" &lt;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>

View File

@@ -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,
) )
} }

View File

@@ -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

View File

@@ -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
)
*/
) )

View 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>

View File

@@ -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
View 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>

View File

@@ -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(

View File

@@ -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

View File

@@ -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
View 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>

View File

@@ -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?,

View File

@@ -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")

View File

@@ -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:

View 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>

View File

@@ -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(), ) {
Box(
modifier = Modifier
.height(72.dp),
) { ) {
toolbar(uiState)
questionContent(uiState)
choices(uiState, onSelect)
// 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(),
) { ) {
@@ -177,23 +150,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),
)
}
}
} }
} }

View File

@@ -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
} }

View File

@@ -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,22 +39,23 @@ 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
@@ -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),

View File

@@ -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),
)
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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))

View File

@@ -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),

View File

@@ -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

View File

@@ -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)) }

View File

@@ -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>