Compare commits

...

19 Commits

Author SHA1 Message Date
Adrian Kuta
75d1ce86eb Update Apk 2025-09-05 10:32:17 +02:00
Adrian Kuta
d6e77be660 Remove dark color scheme 2025-09-05 10:29:21 +02:00
Adrian Kuta
b454701566 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 08:26:48 +02:00
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
Adrian Kuta
12638f33d8 Refactor: Clean up imports and formatting in UI components
This commit addresses minor code style issues by removing unused imports and standardizing formatting across several UI components within the `ui:quiz` module.

Key changes:

- **`ui/quiz/QuizScreen.kt`**:
    - Added `modifier` parameter to `QuizScreenLoading`'s `CircularProgressIndicator`.
- **`ui/quiz/QuizScreenViewModel.kt`**:
    - Improved code formatting for readability, particularly around `timerState` updates and retrieving question times.
- **`ui/quiz/components/TimerBar.kt`**:
    - Removed unused import `androidx.compose.ui.draw.clipToBounds`.
- **`ui/quiz/components/QuestionContent.kt`**:
    - Reordered imports alphabetically.
- **`ui/quiz/components/Choices.kt`**:
    - Removed unused import `androidx.compose.foundation.layout.width`.
- **`ui/quiz/components/Toolbar.kt`**:
    - Removed unused imports `androidx.compose.foundation.layout.fillMaxWidth` and `androidx.compose.foundation.layout.height`.
2025-09-04 21:31:55 +02:00
Adrian Kuta
3194d2a813 Refactor: Extract QuizScreen components into separate files
This commit refactors the `QuizScreen.kt` file by extracting its core UI components into their own respective files under the `ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/components/` directory.

The following components have been moved:
- `Toolbar.kt`: Contains the `Toolbar` composable.
- `QuestionContent.kt`: Contains the `QuestionContent` composable.
- `Choices.kt`: Contains the `Choices`, `ChoiceItem`, `ChoiceItemDefault`, and `ChoiceItemRevealed` composables.
- `TimerBar.kt`: Contains the `TimerBar` composable.

This change improves the organization and maintainability of the `QuizScreen` by breaking it down into smaller, more manageable UI units. The import statements in `QuizScreen.kt` have been updated to reflect these changes. No functional changes were made.
2025-09-04 21:30:00 +02:00
Adrian Kuta
7cd3394098 Refactor: Extract QuizScreen composables and improve timer logic
This commit refactors `QuizScreen.kt` by extracting several composable functions for better organization and readability. It also includes improvements to the timer logic and minor UI adjustments.

Key changes:

- **QuizScreen.kt:**
    - Extracted `QuizScreenLoading` and `QuizScreenSuccess` composables to handle different UI states.
    - Further decomposed `QuizScreenSuccess` into `LazyListScope` extension functions for `toolbar`, `questionContent`, `choices`, `timer`, and `continueButton`. This improves the structure of the `LazyColumn` and allows for more granular recomposition.
    - Improved `TimerBar` logic:
        - Ensured `targetValue` for `animateFloatAsState` is properly calculated to avoid division by zero when `totalSeconds` is 0.
        - Coerced the progress `Float` value to be within `0f` and `1f` before applying it to `fillMaxWidth`.
    - Changed `contentDescription` of the type icon in `QuizType` to `null` as it's decorative.
    - Used `androidx.compose.runtime.remember` for `questionText` in `QuestionContent` to avoid unnecessary recomposition of `HtmlCompat.fromHtml`.
2025-09-04 18:29:45 +02:00
Adrian Kuta
7d38facda5 fix: Correct timer behavior and display
This commit addresses issues with the question timer in `QuizScreen`.

Key changes:

- **QuizScreenViewModel:**
    - Initialize `_remainingTimeSeconds` to `0` instead of `-1` to prevent premature timer display.
    - Start timer only if `timerJob` is `null` (not already running) and it's the first question.
    - Set `timerJob` to `null` when it's cancelled (on choice selection, moving to next question, or finishing the quiz).
    - If a question has no time limit (null or <= 0 seconds), set `_remainingTimeSeconds` to `0` and do not start the `timerJob`.
    - When the timer finishes and no choice was selected, set `_selectedChoiceIndex` to `-1` (indicating time ran out) and ensure `timerJob` is nullified.
- **QuizScreen:**
    - Conditionally display the `TimerBar` only if `selectedChoiceIndex` is `null` (no choice made yet) AND `timerState.totalTimeSeconds` is greater than `0` (meaning there's a valid timer for the current question). This prevents showing a timer when a question has no time limit.
2025-09-04 17:59:36 +02:00
Adrian Kuta
d2fce7e7b9 feat: Implement question timer and navigation between questions
This commit introduces a timer for each question in the `QuizScreen` and enables navigation to the next question upon answering or when the timer expires.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - **QuizScreen.kt:**
        - Refactored `QuizScreen` to use `LazyColumn` for better performance and to support animated item changes.
        - Implemented a "Continue" button that appears after a choice is selected, allowing the user to proceed to the next question.
        - The `Choices` layout was changed from `LazyVerticalGrid` to `FlowRow` for more flexible item arrangement.
        - Added a loading state (`CircularProgressIndicator`) while quiz data is being fetched.
        - Question images are now clipped with rounded corners.
        - `ScreenUiState` (formerly `QuizUiState`) now holds `selectedChoiceIndex` directly instead of a separate `AnswerUiState`.
        - The `onContinue` callback is passed to the `QuizScreen` to handle advancing to the next question.
        - Added `animateContentSize` to the main `LazyColumn` for smoother transitions.
    - **QuizScreenViewModel.kt:**
        - Introduced `QuizUiState` (sealed interface with `Loading` and `Success` states) to represent the state of quiz data fetching.
        - Introduced `ScreenUiState` (sealed interface with `Loading` and `Success` states) to represent the overall screen state, including the current question, selected answer, and timer.
        - Implemented timer logic:
            - A countdown timer starts for each question.
            - The timer is cancelled when an answer is selected.
            - `_remainingTimeSeconds` now defaults to -1 to indicate the timer hasn't started for the current question yet.
        - Implemented `onContinue()` function to:
            - Advance to the `_currentQuestionIndex`.
            - Reset `_selectedChoiceIndex`.
            - Start the timer for the new question.
        - The initial quiz fetch now populates the `quiz` StateFlow.
        - The timer starts automatically when the first question of a successfully loaded quiz is ready.
        - `TimerState` data class was created to encapsulate timer-related information.
2025-09-04 17:51:18 +02:00
Adrian Kuta
41fd729271 feat: Display "Continue" button after answer selection and hide timer
This commit modifies the `QuizScreen` to show a "Continue" button once an answer is selected for the current question. The timer bar is hidden when an answer is chosen.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - In `QuizScreen.kt`:
        - Conditionally display either the `TimerBar` or a `FilledTonalButton` with the text "Continue" based on whether `uiState.answer` is null.
        - The "Continue" button is styled with a grey background and black text.
    - Added a new string resource `continue_text` in `ui/quiz/src/main/res/values/strings.xml`.
2025-09-04 14:09:59 +02:00
Adrian Kuta
f0bd963d2d feat: Implement question timer and update toolbar UI
This commit introduces a timer for questions in the `QuizScreen` and updates the toolbar to display the current question number out of the total.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - In `QuizScreen.kt`:
        - Added a `TimerBar` composable to visually represent the remaining time for a question. This bar animates its width and displays the remaining seconds.
        - Updated the `Toolbar` composable to display the current question index and total number of questions (e.g., "1/10").
        - Passed `currentQuestionIndex`, `totalQuestions`, `totalTimeSeconds`, and `remainingTimeSeconds` from `QuizUiState` to the respective composables.
        - Updated previews to reflect new `QuizUiState` properties.
        - Used `RoundedCornerShape(percent = 50)` for more consistent rounded corners in the `Toolbar`.
    - In `QuizScreenViewModel.kt`:
        - Added `_remainingTimeSeconds` MutableStateFlow to track the countdown.
        - Modified `QuizUiState` to include `currentQuestionIndex`, `totalQuestions`, `totalTimeSeconds`, and `remainingTimeSeconds`.
        - Implemented `startCountdown()` logic to decrease `_remainingTimeSeconds` every second.
        - The timer is started when the ViewModel is initialized and for each new question.
        - When a choice is selected, the timer is cancelled.
        - If the timer runs out before a choice is selected, `_selectedChoiceIndex` is set to -1 to indicate a timeout.
        - The `uiState` flow now combines `getQuizUseCase()`, `_selectedChoiceIndex`, and `_remainingTimeSeconds` to derive the `QuizUiState`.
- **Design System (`core:designsystem` module):**
    - Added `Purple` color definition in `Color.kt` for use in the `TimerBar`.
    - Reordered color definitions alphabetically.
2025-09-04 13:56:33 +02:00
Adrian Kuta
710dedb0cc refactor: Format code with spotless
This commit applies automated code formatting using Spotless across multiple modules. The changes primarily involve adjustments to trailing commas, spacing, and import statements to ensure consistency with the project's coding style.

Key changes include:

- **Kotlin Files:**
    - Added trailing commas to multi-line parameter lists, argument lists, and collection literals in various Kotlin files across `app`, `core:designsystem`, `core:network`, `domain`, and `model:data` modules.
    - Standardized spacing around operators and keywords.
    - Optimized import statements in test files (`ExampleUnitTest.kt`, `ExampleInstrumentedTest.kt`).
- **XML Files:**
    - Reformatted XML attributes in drawable vector files (`ic_*.xml`) within the `core:designsystem` module for better readability.
- **JSON Files:**
    - Reformatted `sample_quiz.json` in `core:network` test resources for consistent structure.
- **Detekt Configuration:**
    - Updated `detekt.yml` files in `app` and `core:designsystem` to adjust ignored annotations lists, ensuring proper spacing (e.g., `['Composable']` to `[ 'Composable' ]`).
2025-09-03 23:46:09 +02:00
Adrian Kuta
7568abb775 feat: Implement interactive quiz screen with answer revealed state
This commit enhances the `QuizScreen` to be interactive, allowing users to select choices and view revealed answers. It also introduces HTML parsing for question text and adds new design elements like icons and colors.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - `QuizScreen`:
        - Now takes an `onSelect` callback to handle choice selection.
        - `Choices` composable updated to display choices in a `LazyVerticalGrid` and handles click events.
        - Introduced `ChoiceItem` which branches into `ChoiceItemDefault` (for selectable choices) and `ChoiceItemRevealed` (for displaying correct/incorrect answers).
        - `ChoiceItemDefault` displays choices with background colors and icons based on their index.
        - `ChoiceItemRevealed` displays choices with background colors indicating correctness (green for correct, red for incorrect selected, pink for incorrect unselected) and appropriate icons (tick for correct, cross for wrong).
        - `QuestionContent` now parses HTML in the question text using `HtmlCompat` and a new `toAnnotatedString` extension.
        - Image loading in `QuestionContent` uses `ContentScale.FillWidth` and `heightIn(min = 200.dp)`.
        - Added a new preview `QuizScreenRevealedAnswerPreview` to showcase the revealed answer state.
    - `QuizScreenViewModel`:
        - Now manages `_selectedChoiceIndex` to track the user's answer.
        - `uiState` is now a combination of the fetched quiz and the `_selectedChoiceIndex`, producing `QuizUiState` which includes an `AnswerUiState`.
        - `AnswerUiState` holds the `selectedChoiceIndex`.
        - Implemented `onChoiceSelected(index: Int)` to update the selected choice.
- **Design System (`core:designsystem` module):**
    - Added `TextUtils.kt` with a `Spanned.toAnnotatedString()` extension function to convert HTML formatted text (from `HtmlCompat`) into Jetpack Compose `AnnotatedString`.
    - Added new color definitions: `Pink`, `Red`, `Red2`, `Blue2`, `Yellow3`, `Green`, `Green2`.
    - Added a `contrastiveTo(color: Color)` utility function to determine a contrasting text color (black or white) for a given background color.
    - Added new vector drawables for choice shapes and correctness indicators:
        - `ic_circle.xml`
        - `ic_correct.xml`
        - `ic_diamond.xml`
        - `ic_square.xml`
        - `ic_triangle.xml`
        - `ic_wrong.xml`
    - Added Detekt configuration file (`config/detekt/detekt.yml`) for the design system module.
- **Domain Layer (`domain` module):**
    - `Question.image` is now nullable (`String?`).
    - `Choice.correct` is now non-nullable (`Boolean`).
- **Network Layer (`core:network` module):**
    - `ChoiceDto.correct` is now non-nullable (`Boolean`) to align with the domain model.
- **Project Configuration:**
    - Added `.editorconfig` with Kotlin specific trailing comma settings.
    - Minor reordering of dependencies in `gradle/libs.versions.toml`.
2025-09-03 23:45:29 +02:00
Adrian Kuta
45550ecf76 feat: Enhance QuizScreen UI and introduce core design system module
This commit significantly revamps the `QuizScreen` UI to display question details including image and text, and introduces a new `core:designsystem` module to centralize theme, colors, typography, and drawable resources.

Key changes:

- **UI Layer (`ui:quiz` module):**
    - Updated `QuizScreen.kt`:
        - Implemented a background image for the screen.
        - Added a `Toolbar` composable to display question progress and type.
        - Created `QuestionContent` composable to show the question image (using Coil for image loading) and text.
        - Added a placeholder `Choices` composable (currently an empty `LazyVerticalGrid`).
        - Applied `fillMaxSize()` to the main `QuizScreen` modifier.
        - Included a `@Preview` for `QuizScreen` with sample data.
    - Modified `QuizScreenViewModel.kt` to update `QuizUiState` with the first `Question` from the fetched quiz.
    - Added a `quiz` string resource in `strings.xml`.
    - Added dependencies for Coil (compose and okhttp) in `ui/quiz/build.gradle.kts`.
- **Core Design System (`core:designsystem` module):**
    - Created a new Android library module `core:designsystem`.
    - Moved `Color.kt`, `Theme.kt`, and `Type.kt` from `app/src/main/java/dev/adriankuta/kahootquiz/ui/theme` to this new module.
    - Added a new `Grey` color to `Color.kt`.
    - Added `bg_image.webp` and `ic_type.xml` drawable resources.
    - Configured the module with `kahootquiz.android.library.compose` plugin.
- **App Module (`app` module):**
    - Updated `MainActivity.kt` to import `KahootQuizTheme` from the new `core.designsystem` package.
    - Added `implementation(projects.core.designsystem)` dependency in `app/build.gradle.kts`.
- **Domain Layer (`domain` module):**
    - Made several fields in `Question.kt` and `Choice.kt` nullable and provided default null values to accommodate potential missing data from the API.
    - Specifically, `Question.image` is now non-nullable (`String`).
- **Build System:**
    - Added `coilCompose` and `coilNetworkOkhttp` versions to `gradle/libs.versions.toml`.
    - Included `:core:designsystem` in `settings.gradle.kts`.
2025-09-03 21:57:18 +02:00
82 changed files with 2391 additions and 318 deletions

4
.editorconfig Executable file
View File

@@ -0,0 +1,4 @@
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true

4
.idea/gradle.xml generated
View File

@@ -26,10 +26,10 @@
<option value="$PROJECT_DIR$/build-logic" />
<option value="$PROJECT_DIR$/build-logic/convention" />
<option value="$PROJECT_DIR$/core" />
<option value="$PROJECT_DIR$/core/designsystem" />
<option value="$PROJECT_DIR$/core/network" />
<option value="$PROJECT_DIR$/data" />
<option value="$PROJECT_DIR$/domain" />
<option value="$PROJECT_DIR$/model" />
<option value="$PROJECT_DIR$/model/data" />
<option value="$PROJECT_DIR$/ui" />
<option value="$PROJECT_DIR$/ui/quiz" />
</set>

BIN
App.apk Normal file

Binary file not shown.

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# KahootQuiz — Interview Challenge
This project is an implementation for an interview-style challenge. It demonstrates a clean, modular
Android architecture with a focus on separation of concerns, convention plugins for Gradle, and
pragmatic Kotlin usage.
## TL;DR
- Only image media is supported right now.
- Slider question type is not supported.
- There is no end/completion screen yet.
- Errors in ViewModels are caught but not yet handled (no user-facing error states/actions).
## Project Overview
- Multi-module, clean architecture:
- `core/` — common utilities (e.g., networking).
- `domain/` — pure domain models and repository abstractions, domain models.
- `data/` — repository implementations, mappers.
- `ui/` — feature UI modules (e.g., `ui/quiz`).
- Convention plugins are used to centralize and reuse Gradle configuration across modules (see
`build-logic/`).
- Kotlin-first approach using language features to keep code concise and readable.
## How to Build & Run
1. Requirements:
- Android Studio
- JDK 21
- Gradle wrapper included
2. Steps:
- Open the project in Android Studio.
- Sync Gradle.
- Run the `app` configuration on a device/emulator.
If you prefer the command line: `./gradlew assembleDebug` and then install the generated APK.
## Architecture Details
- Data flow follows a standard clean pattern:
- `domain.repositories.QuizRepository` defines the contract.
- `data.QuizRepositoryImpl` uses `core.network.retrofit.QuizApi` + mappers to produce
`domain.models.Quiz`.
- UI consumes domain via ViewModels and exposes a `UiState`.
- The code emphasizes separation of concerns and testability.
## Current Limitations & Known Issues
- Media support:
- Only `image` media is supported in the quiz content.
- Other media types are not supported.
- Question types:
- Slider answers are not supported yet.
- UX flow:
- There is no end/completion screen after the quiz finishes.
- Error handling:
- Exceptions are caught in ViewModels but not handled (no retry, no error UI, no telemetry hooks
yet).
## Suggested Improvements
1. Introduce a UI-specific model for the Quiz screen
- The domain model `Quiz` is relatively complex and currently used directly in `UiState`.
- Add a dedicated, lean UI data class that contains only the data relevant to the quiz screen.
- Benefits: Improved clarity for UI developers, simpler previews, easier testing/mocking, and
better forward-compatibility when domain evolves.
2. Expand Unit Test Coverage
- Currently there is only one unit test for parsing a sample JSON API response.
- Add tests for:
- ViewModel state transitions (loading/success/error).
- Mapping edge cases (e.g., missing fields, unsupported media types).
- Navigation/flow for various question types.
3. Error Handling Strategy
- Map exceptions to user-friendly UI states with retry actions.
- Add telemetry/logging hooks for observability.
4. Feature Completeness
- Implement slider answer type.
- Add an end/completion screen with score summary and restart/share options.
- Consider support for additional media types (video/audio), with graceful fallbacks.
5. Transitions between questions could be more smooth.
## Extra: Related Work I Can Share
I can share more complex code from my private app that is published on the Google Play Store.
Additionally, I have a secondary project — an AI Agent implemented in TypeScript using 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

@@ -20,17 +20,17 @@ android {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
}
}
dependencies {
implementation(projects.ui.quiz)
implementation(projects.core.designsystem)
implementation(projects.domain)
implementation(projects.model.data)
implementation(projects.data)
implementation(projects.ui.quiz)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.hilt.navigation.compose)

View File

@@ -8,18 +8,18 @@ naming:
complexity:
LongParameterList:
ignoreAnnotated: ['Composable']
ignoreAnnotated: [ 'Composable' ]
TooManyFunctions:
ignoreAnnotatedFunctions: ['Preview']
ignoreAnnotatedFunctions: [ 'Preview' ]
style:
MagicNumber:
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotated: ['Composable']
ignoreAnnotated: [ 'Composable' ]
UnusedPrivateMember:
ignoreAnnotated: ['Composable']
ignoreAnnotated: [ 'Composable' ]
# Deviations from defaults
formatting:

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,13 +1,11 @@
package dev.adriankuta.kahootquiz
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*

View File

@@ -1,20 +1,31 @@
package dev.adriankuta.kahootquiz
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
@Composable
fun KahootQuizApp(
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Scaffold(
contentWindowInsets = WindowInsets.safeDrawing,
modifier = modifier,
) { paddingValues ->
KahootQuizNavGraph(modifier = modifier.padding(paddingValues))
Image(
painter = painterResource(id = DesignR.drawable.bg_image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
KahootQuizNavGraph(modifier = Modifier.padding(paddingValues))
}
}
}

View File

@@ -16,8 +16,8 @@ fun KahootQuizNavGraph(
NavHost(
navController = navController,
startDestination = QuizRoute,
modifier = modifier
modifier = modifier,
) {
quizScreen()
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import dev.adriankuta.kahootquiz.ui.theme.KahootQuizTheme
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

View File

@@ -4,4 +4,4 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication: Application()
class MyApplication : Application()

View File

@@ -1,11 +0,0 @@
package dev.adriankuta.kahootquiz.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -1,58 +0,0 @@
package dev.adriankuta.kahootquiz.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun KahootQuizTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -1,34 +0,0 @@
package dev.adriankuta.kahootquiz.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -1,9 +1,8 @@
package dev.adriankuta.kahootquiz
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
@@ -14,4 +13,4 @@ class ExampleUnitTest {
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
}

View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.kahootquiz.android.library.compose)
}
android {
namespace = "dev.adriankuta.kahootquiz.core.designsystem"
}

View File

@@ -0,0 +1,33 @@
# Exceptions for compose. See https://detekt.dev/docs/introduction/compose
naming:
FunctionNaming:
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
TopLevelPropertyNaming:
constantPattern: '[A-Z][A-Za-z0-9]*'
complexity:
LongParameterList:
ignoreAnnotated: [ 'Composable' ]
TooManyFunctions:
ignoreAnnotatedFunctions: [ 'Preview' ]
style:
MagicNumber:
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotated: [ 'Composable' ]
UnusedPrivateMember:
ignoreAnnotated: [ 'Composable' ]
# Deviations from defaults
formatting:
TrailingCommaOnCallSite:
active: true
autoCorrect: true
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: true
autoCorrect: true
useTrailingCommaOnDeclarationSite: true

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

@@ -0,0 +1,30 @@
package dev.adriankuta.kahootquiz.core.designsystem
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
@Composable
fun contrastiveTo(color: Color): Color = if (color.luminance() < 0.5) {
Color.White
} else {
Color.Black
}
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val Blue2 = Color(0xFF1368CE)
val Green = Color(0xFF66BF39)
val Green2 = Color(0xFF26890C)
val Grey = Color(0xFFFAFAFA)
val Pink = Color(0xFFFF99AA)
val Purple = Color(0xFF864CBF)
val Red = Color(0xFFFF3355)
val Red2 = Color(0xFFE21B3C)
val Yellow3 = Color(0xFFD89E00)

View File

@@ -0,0 +1,49 @@
package dev.adriankuta.kahootquiz.core.designsystem
import android.graphics.Typeface
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
val spanned = this@toAnnotatedString
append(spanned.toString())
getSpans(0, spanned.length, Any::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
Typeface.BOLD_ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
),
start,
end,
)
}
is UnderlineSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start,
end,
)
is ForegroundColorSpan -> addStyle(
SpanStyle(color = Color(span.foregroundColor)),
start,
end,
)
}
}
}

View File

@@ -0,0 +1,26 @@
package dev.adriankuta.kahootquiz.core.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun KahootQuizTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = LightColorScheme,
typography = Typography,
content = content,
)
}

View File

@@ -0,0 +1,18 @@
package dev.adriankuta.kahootquiz.core.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#ffffff"
android:pathData="M20,10C14.477,10 10,14.477 10,20C10,25.523 14.477,30 20,30C25.523,30 30,25.523 30,20C30,14.477 25.523,10 20,10Z" />
<path
android:fillColor="#00000000"
android:pathData="M20,9.5C25.799,9.5 30.5,14.201 30.5,20C30.5,25.799 25.799,30.5 20,30.5C14.201,30.5 9.5,25.799 9.5,20C9.5,14.201 14.201,9.5 20,9.5Z"
android:strokeWidth="1"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#67BF38"
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeWidth="2"
android:strokeColor="#26890C" />
<path
android:fillColor="#ffffff"
android:pathData="M25.237,12.219L28.298,14.723L18.157,27.123L11.651,21.662L14.296,18.722L17.722,21.409L25.237,12.219Z" />
<path
android:fillColor="#00000000"
android:pathData="M25.554,11.831L28.615,14.336L29.001,14.653L28.685,15.039L18.545,27.44L18.224,27.832L17.836,27.507L11.33,22.045L10.933,21.713L11.279,21.328L13.925,18.388L14.237,18.041L14.604,18.329L17.645,20.713L24.85,11.902L25.167,11.515L25.554,11.831Z"
android:strokeWidth="1"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
<group>
<clip-path android:pathData="M25.237,12.219L28.298,14.723L18.157,27.123L11.651,21.662L14.296,18.722L17.722,21.409L25.237,12.219ZM25.554,11.831L28.615,14.336L29.001,14.653L28.685,15.039L18.545,27.44L18.224,27.832L17.836,27.507L11.33,22.045L10.933,21.713L11.279,21.328L13.925,18.388L14.237,18.041L14.604,18.329L17.645,20.713L24.85,11.902L25.167,11.515L25.554,11.831Z" />
</group>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#ffffff"
android:pathData="M20,8.75L8.75,20.004L20,31.25L31.25,20.001L20,8.75Z" />
<path
android:fillColor="#00000000"
android:pathData="M20.354,8.396L31.604,19.647L31.957,20.001L31.604,20.354L20.354,31.604L20,31.957L19.646,31.604L8.396,20.357L8.043,20.004L8.396,19.65L19.646,8.396L20,8.043L20.354,8.396Z"
android:strokeWidth="1"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="41dp"
android:viewportWidth="40"
android:viewportHeight="41">
<path
android:fillColor="#ffffff"
android:pathData="M28.75,11.476H11.25V28.976H28.75V11.476Z" />
<path
android:fillColor="#00000000"
android:pathData="M29.25,10.976V29.476H10.75V10.976H29.25Z"
android:strokeWidth="1"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#ffffff"
android:pathData="M20,11.25L8.75,28.75H31.25L20,11.25Z" />
<path
android:fillColor="#00000000"
android:pathData="M20.421,10.979L31.671,28.479L32.166,29.25H7.834L8.329,28.479L19.579,10.979L20,10.325L20.421,10.979Z"
android:strokeWidth="1"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
</vector>

View File

@@ -0,0 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F2F2F2"
android:fillType="evenOdd"
android:pathData="M12,22C6.477,22 2,17.523 2,12C2,6.477 6.477,2 12,2C17.523,2 22,6.477 22,12C22,17.523 17.523,22 12,22Z" />
<path
android:fillColor="#333333"
android:fillType="evenOdd"
android:pathData="M19.802,21.989L7.899,23.992C7.584,24.045 7.285,23.833 7.232,23.517L3.724,2.678C3.671,2.363 3.883,2.064 4.198,2.011L16.101,0.008C16.416,-0.045 16.715,0.168 16.768,0.483L20.276,21.322C20.33,21.637 20.117,21.936 19.802,21.989Z" />
<path
android:fillColor="#FAFAFA"
android:fillType="evenOdd"
android:pathData="M19.083,20.611L8.127,22.455C7.951,22.485 7.784,22.366 7.754,22.189L4.538,3.09C4.509,2.913 4.627,2.746 4.804,2.716L15.759,0.872C15.936,0.843 16.103,0.962 16.133,1.139L19.349,20.238C19.378,20.414 19.259,20.582 19.083,20.611Z" />
<path
android:fillColor="#26890C"
android:fillType="evenOdd"
android:pathData="M18.499,20.05L13.849,20.833C13.79,20.843 13.733,20.803 13.724,20.744L12.306,12.325C12.296,12.266 12.336,12.209 12.395,12.199L17.045,11.417C17.104,11.407 17.16,11.447 17.17,11.506L18.587,19.925C18.597,19.984 18.558,20.04 18.499,20.05Z" />
<path
android:fillColor="#FFA602"
android:fillType="evenOdd"
android:pathData="M12.967,20.981L8.317,21.764C8.258,21.774 8.202,21.733 8.192,21.674L6.775,13.255C6.765,13.196 6.805,13.14 6.864,13.13L11.514,12.347C11.573,12.337 11.629,12.377 11.639,12.437L13.057,20.856C13.066,20.915 13.026,20.971 12.967,20.981Z" />
<path
android:fillColor="#1368CE"
android:fillType="evenOdd"
android:pathData="M16.947,10.835L12.297,11.618C12.238,11.628 12.182,11.588 12.172,11.529L10.64,2.434C10.631,2.374 10.67,2.318 10.73,2.308L15.379,1.526C15.439,1.516 15.495,1.556 15.505,1.615L17.036,10.71C17.046,10.769 17.006,10.825 16.947,10.835Z" />
<path
android:fillColor="#E11C3C"
android:fillType="evenOdd"
android:pathData="M11.416,11.766L6.766,12.548C6.707,12.558 6.651,12.518 6.641,12.459L5.109,3.364C5.099,3.305 5.139,3.249 5.199,3.239L9.848,2.456C9.907,2.446 9.964,2.486 9.974,2.546L11.505,11.641C11.515,11.7 11.475,11.756 11.416,11.766Z" />
<path
android:fillColor="#FAFAFA"
android:fillType="evenOdd"
android:pathData="M7.317,8.677L8.107,6.504L9.544,8.316L7.317,8.677Z" />
<path
android:fillColor="#FAFAFA"
android:fillType="evenOdd"
android:pathData="M13.596,5.437L15.014,6.416L14.017,7.822L12.598,6.842L13.596,5.437Z" />
<path
android:fillColor="#FAFAFA"
android:fillType="evenOdd"
android:pathData="M10.79,16.727C10.704,16.175 10.188,15.796 9.637,15.881C9.086,15.967 8.709,16.484 8.795,17.037C8.881,17.59 9.397,17.968 9.948,17.883C10.498,17.798 10.875,17.28 10.79,16.727Z" />
<path
android:fillColor="#FAFAFA"
android:fillType="evenOdd"
android:pathData="M16.618,16.843L14.628,17.181L14.288,15.184L16.278,14.846L16.618,16.843Z" />
</vector>

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF3355"
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0" />
<path
android:fillColor="#00000000"
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeWidth="2"
android:strokeColor="#FF3355" />
<path
android:fillColor="#00000000"
android:pathData="M20,20m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeWidth="2"
android:strokeAlpha="0.15"
android:strokeColor="#000000" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M12.04,24.636L15.355,27.95L19.995,23.31L24.636,27.95L27.95,24.636L23.31,19.995L27.95,15.355L24.636,12.04L19.995,16.681L15.355,12.04L12.04,15.355L16.681,19.995L12.04,24.636Z" />
<group>
<clip-path android:pathData="M5.853,5.721h28.284v28.284h-28.284z" />
<clip-path
android:fillType="evenOdd"
android:pathData="M12.04,24.636L15.355,27.95L19.995,23.31L24.636,27.95L27.95,24.636L23.31,19.995L27.95,15.355L24.636,12.04L19.995,16.681L15.355,12.04L12.04,15.355L16.681,19.995L12.04,24.636Z" />
<path
android:fillAlpha="0.15"
android:fillColor="#000000"
android:pathData="M15.355,27.95L14.648,28.657L15.355,29.365L16.062,28.657L15.355,27.95ZM12.04,24.636L11.333,23.929L10.626,24.636L11.333,25.343L12.04,24.636ZM19.995,23.31L20.702,22.603L19.995,21.896L19.288,22.603L19.995,23.31ZM24.636,27.95L23.929,28.657L24.636,29.365L25.343,28.657L24.636,27.95ZM27.95,24.636L28.657,25.343L29.365,24.636L28.657,23.929L27.95,24.636ZM23.31,19.995L22.603,19.288L21.896,19.995L22.603,20.702L23.31,19.995ZM27.95,15.355L28.657,16.062L29.365,15.355L28.657,14.648L27.95,15.355ZM24.636,12.04L25.343,11.333L24.636,10.626L23.929,11.333L24.636,12.04ZM19.995,16.681L19.288,17.388L19.995,18.095L20.702,17.388L19.995,16.681ZM15.355,12.04L16.062,11.333L15.355,10.626L14.648,11.333L15.355,12.04ZM12.04,15.355L11.333,14.648L10.626,15.355L11.333,16.062L12.04,15.355ZM16.681,19.995L17.388,20.702L18.095,19.995L17.388,19.288L16.681,19.995ZM16.062,27.243L12.748,23.929L11.333,25.343L14.648,28.657L16.062,27.243ZM19.288,22.603L14.648,27.243L16.062,28.657L20.702,24.017L19.288,22.603ZM25.343,27.243L20.702,22.603L19.288,24.017L23.929,28.657L25.343,27.243ZM27.243,23.929L23.929,27.243L25.343,28.657L28.657,25.343L27.243,23.929ZM22.603,20.702L27.243,25.343L28.657,23.929L24.017,19.288L22.603,20.702ZM27.243,14.648L22.603,19.288L24.017,20.702L28.657,16.062L27.243,14.648ZM23.929,12.748L27.243,16.062L28.657,14.648L25.343,11.333L23.929,12.748ZM20.702,17.388L25.343,12.748L23.929,11.333L19.288,15.974L20.702,17.388ZM14.648,12.748L19.288,17.388L20.702,15.974L16.062,11.333L14.648,12.748ZM12.748,16.062L16.062,12.748L14.648,11.333L11.333,14.648L12.748,16.062ZM17.388,19.288L12.748,14.648L11.333,16.062L15.974,20.702L17.388,19.288ZM12.748,25.343L17.388,20.702L15.974,19.288L11.333,23.929L12.748,25.343Z" />
</group>
</vector>

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

@@ -5,7 +5,7 @@ package dev.adriankuta.kahootquiz.core.network.models
data class LanguageInfoDto(
val language: String?,
val lastUpdatedOn: Long?,
val readAloudSupported: Boolean?
val readAloudSupported: Boolean?,
)
// Minimal channel info
@@ -16,5 +16,5 @@ data class ChannelDto(val id: String?)
data class PointDto(
val x: Int?,
val y: Int?
val y: Int?,
)

View File

@@ -4,5 +4,5 @@ package dev.adriankuta.kahootquiz.core.network.models
data class ContentTagsDto(
val curriculumCodes: List<String>?,
val generatedCurriculumCodes: List<String>?
val generatedCurriculumCodes: List<String>?,
)

View File

@@ -13,14 +13,14 @@ data class CoverMetadataDto(
val height: Int?,
val extractedColors: List<ExtractedColorDto>?,
val blurhash: String?,
val crop: CropDto?
val crop: CropDto?,
)
// Color extracted from cover image
data class ExtractedColorDto(
val swatch: String?,
val rgbHex: String?
val rgbHex: String?,
)
// Crop descriptor
@@ -28,5 +28,5 @@ data class ExtractedColorDto(
data class CropDto(
val origin: PointDto?,
val target: PointDto?,
val circular: Boolean?
val circular: Boolean?,
)

View File

@@ -7,7 +7,7 @@ data class MetadataDto(
val duplicationProtection: Boolean?,
val featuredListMemberships: List<FeaturedListMembershipDto>?,
val lastEdit: LastEditDto?,
val versionMetadata: VersionMetadataDto?
val versionMetadata: VersionMetadataDto?,
)
// Access settings
@@ -15,14 +15,14 @@ data class MetadataDto(
data class AccessDto(
val groupRead: List<String>?,
val folderGroupIds: List<String>?,
val features: List<String>?
val features: List<String>?,
)
// Featured list membership
data class FeaturedListMembershipDto(
val list: String?,
val addedAt: Long?
val addedAt: Long?,
)
// Last edit information
@@ -30,7 +30,7 @@ data class FeaturedListMembershipDto(
data class LastEditDto(
val editorUserId: String?,
val editorUsername: String?,
val editTimestamp: Long?
val editTimestamp: Long?,
)
// Version metadata
@@ -38,5 +38,5 @@ data class LastEditDto(
data class VersionMetadataDto(
val version: Int?,
val created: Long?,
val creator: String?
val creator: String?,
)

View File

@@ -17,15 +17,15 @@ data class QuestionDto(
val questionFormat: Int?,
val languageInfo: LanguageInfoDto?,
val media: List<MediaItemDto>?,
val choiceRange: ChoiceRangeDto?
val choiceRange: ChoiceRangeDto?,
)
// Choice option
data class ChoiceDto(
val answer: String?,
val correct: Boolean?,
val languageInfo: LanguageInfoDto?
val correct: Boolean,
val languageInfo: LanguageInfoDto?,
)
// Optional video attachment
@@ -35,7 +35,7 @@ data class VideoDto(
val startTime: Int?,
val endTime: Int?,
val service: String?,
val fullUrl: String?
val fullUrl: String?,
)
// Image metadata appearing in multiple places
@@ -50,7 +50,7 @@ data class ImageMetadataDto(
val width: Int? = null,
val height: Int? = null,
val effects: List<String>? = null,
val crop: CropDto? = null
val crop: CropDto? = null,
)
// Generic media item on question
@@ -67,7 +67,7 @@ data class MediaItemDto(
val resources: String? = null,
val width: Int? = null,
val height: Int? = null,
val crop: CropDto? = null
val crop: CropDto? = null,
)
// Slider range for "slider" question type
@@ -77,5 +77,5 @@ data class ChoiceRangeDto(
val end: Int?,
val step: Int?,
val correct: Int?,
val tolerance: Int?
val tolerance: Int?,
)

View File

@@ -32,5 +32,5 @@ data class QuizResponseDto(
val hasRestrictedContent: Boolean?,
val type: String?,
val created: Long?,
val modified: Long?
val modified: Long?,
)

View File

@@ -42,8 +42,14 @@
],
"blurhash": "UuJ*#Qxtx]xaCAj[W=WqEma}M{R*M|WVn#j?",
"crop": {
"origin": {"x": 227, "y": 0},
"target": {"x": 1948, "y": 1299},
"origin": {
"x": 227,
"y": 0
},
"target": {
"x": 1948,
"y": 1299
},
"circular": false
}
},
@@ -55,8 +61,24 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "True", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "False", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "True",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "False",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"layout": "TRUE_FALSE",
"image": "https://media.kahoot.it/b2709905-1c6e-45a0-9cc1-34c6580495e5",
@@ -71,9 +93,18 @@
"height": 1406
},
"resources": "mikroman6/Moment/Getty Images",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -83,15 +114,56 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "A monument to the god Ra", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "A tomb", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "A momument to a great war victory", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "A temple", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "A monument to the god Ra",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "A tomb",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "A momument to a great war victory",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "A temple",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"resources": "",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": [
{
"type": "background_image",
@@ -105,7 +177,17 @@
"resources": "Nick Brundle Photography/Moment/Getty Images",
"width": 2309,
"height": 1299,
"crop": {"origin": {"x": 227, "y": 0}, "target": {"x": 1948, "y": 1299}, "circular": false}
"crop": {
"origin": {
"x": 227,
"y": 0
},
"target": {
"x": 1948,
"y": 1299
},
"circular": false
}
}
]
},
@@ -116,10 +198,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "As a tourist destination", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "A monument to Ninurta, the god of farmers", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "An engagement gift from a king to his future queen", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "A gift for the king's wife", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "As a tourist destination",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "A monument to Ninurta, the god of farmers",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "An engagement gift from a king to his future queen",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "A gift for the king's wife",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/7bce7efb-3d94-495c-905f-9c14190b7910",
"imageMetadata": {
@@ -129,9 +243,18 @@
"effects": []
},
"resources": "https://upload.wikimedia.org/wikipedia/commons/a/ae/Hanging_Gardens_of_Babylon.jpg CC0",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -141,10 +264,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "Greece", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Turkey", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Syria", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Iran", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "Greece",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Turkey",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Syria",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Iran",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/f999f2a2-5450-4821-a3c8-94288720bd46",
"imageMetadata": {
@@ -154,9 +309,18 @@
"effects": []
},
"resources": "Zee Prime at cs.wikipedia [GFDL (http://www.gnu.org/copyleft/fdl.html), CC-BY-SA-3.0 (http://creativecommons.org/licenses/by-sa/3.0/) or CC BY-SA 2.5 (https://creativecommons.org/licenses/by-sa/2.5)], from Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/1/1d/Miniaturk_009.jpg",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -166,10 +330,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "To become famous", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "It was an accident", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "He was angry at the gods", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Because of a bet", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "To become famous",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "It was an accident",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "He was angry at the gods",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Because of a bet",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/fe2c5c06-6d2e-4a5a-9441-a9c77391130e_opt",
"imageMetadata": {
@@ -179,9 +375,19 @@
"effects": []
},
"resources": " [Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/a/a9/Temple_of_Artemis.jpg",
"video": {"id": "", "startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"id": "",
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -191,10 +397,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "Sparta", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Athens", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Olympia", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Delphi", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "Sparta",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Athens",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Olympia",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Delphi",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/9074a275-1874-4cb9-9c9f-248173ceae9d",
"imageMetadata": {
@@ -202,12 +440,31 @@
"contentType": "image/*",
"resources": " [Public domain or Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/6/66/Le_Jupiter_Olympien_ou_l%27art_de_la_sculpture_antique.jpg",
"effects": [],
"crop": {"origin": {"x": 53, "y": 0}, "target": {"x": 577, "y": 866}, "circular": false}
"crop": {
"origin": {
"x": 53,
"y": 0
},
"target": {
"x": 577,
"y": 866
},
"circular": false
}
},
"resources": " [Public domain or Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/6/66/Le_Jupiter_Olympien_ou_l%27art_de_la_sculpture_antique.jpg",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -217,10 +474,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "Darius", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Xerxes", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Cyrus", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Mausoleus", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "Darius",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Xerxes",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Cyrus",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Mausoleus",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/38f43ef3-4507-4f11-ae33-f3e833a47d19",
"imageMetadata": {
@@ -234,9 +523,18 @@
"height": 1414
},
"resources": "MirageC/Moment/Getty Images",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -245,8 +543,24 @@
"time": 60000,
"pointsMultiplier": 2,
"choices": [
{"answer": "Helios", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "helios", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "Helios",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "helios",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/d4ccbf4e-1026-46ad-ab35-84dc17c4d3a0_opt",
"imageMetadata": {
@@ -254,12 +568,32 @@
"contentType": "image/*",
"resources": "By gravure sur bois de Sidney Barclay numérisée Google [Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/5/5f/Colosse_de_Rhodes_%28Barclay%29.jpg",
"effects": [],
"crop": {"origin": {"x": 49, "y": 83}, "target": {"x": 531, "y": 796}, "circular": false}
"crop": {
"origin": {
"x": 49,
"y": 83
},
"target": {
"x": 531,
"y": 796
},
"circular": false
}
},
"resources": "By gravure sur bois de Sidney Barclay numérisée Google [Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/5/5f/Colosse_de_Rhodes_%28Barclay%29.jpg",
"video": {"id": "", "startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"id": "",
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -269,10 +603,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "Fire", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Earthquake", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Tidal Wave", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "Storm", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "Fire",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Earthquake",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Tidal Wave",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "Storm",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/e2d22765-942b-4dbd-9fd6-d71142d775c3",
"imageMetadata": {
@@ -280,12 +646,31 @@
"contentType": "image/*",
"resources": "Emad Victor SHENOUDA [Attribution], from Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/3/33/PHAROS2013-3000x2250.jpg",
"effects": [],
"crop": {"origin": {"x": 0, "y": 10}, "target": {"x": 1024, "y": 683}, "circular": false}
"crop": {
"origin": {
"x": 0,
"y": 10
},
"target": {
"x": 1024,
"y": 683
},
"circular": false
}
},
"resources": "Emad Victor SHENOUDA [Attribution], from Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/3/33/PHAROS2013-3000x2250.jpg",
"video": {"startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -295,10 +680,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "The Colossus of Rhodes", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Lighthouse of Alexandria", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Mausoleum at Halicarnassus", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Great Pyramid of Giza", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "The Colossus of Rhodes",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Lighthouse of Alexandria",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Mausoleum at Halicarnassus",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Great Pyramid of Giza",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/19382163-196f-495d-9a84-c2d8c3fd716c",
"imageMetadata": {
@@ -306,12 +723,32 @@
"contentType": "image/*",
"resources": "By The original uploader was Mark22 at English Wikipedia (Transferred from en.wikipedia to Commons.) [Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/b/b7/SevenWondersOfTheWorld.png",
"effects": [],
"crop": {"origin": {"x": 19, "y": 0}, "target": {"x": 491, "y": 736}, "circular": false}
"crop": {
"origin": {
"x": 19,
"y": 0
},
"target": {
"x": 491,
"y": 736
},
"circular": false
}
},
"resources": "By The original uploader was Mark22 at English Wikipedia (Transferred from en.wikipedia to Commons.) [Public domain], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/b/b7/SevenWondersOfTheWorld.png",
"video": {"id": "", "startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"id": "",
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -319,7 +756,13 @@
"question": "How many of the Seven Wonders still exist?",
"time": 20000,
"pointsMultiplier": 2,
"choiceRange": {"start": 0, "end": 7, "step": 1, "correct": 1, "tolerance": 0},
"choiceRange": {
"start": 0,
"end": 7,
"step": 1,
"correct": 1,
"tolerance": 0
},
"image": "https://media.kahoot.it/b431b3aa-4a46-49c9-b4ac-aa1dde40333f",
"imageMetadata": {
"id": "b431b3aa-4a46-49c9-b4ac-aa1dde40333f",
@@ -328,9 +771,19 @@
"effects": []
},
"resources": "By Kandi [GFDL (http://www.gnu.org/copyleft/fdl.html) or CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0)], from Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/a/a4/Seven_Wonders_of_the_Ancient_World.png",
"video": {"id": "", "startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"id": "",
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
},
{
@@ -340,10 +793,42 @@
"points": true,
"pointsMultiplier": 1,
"choices": [
{"answer": "The Great Pyramid of Giza", "correct": true, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Temple of Artemis", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Mausoleum at Halicarnassus", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}},
{"answer": "The Colossus of Rhodes", "correct": false, "languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true}}
{
"answer": "The Great Pyramid of Giza",
"correct": true,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Temple of Artemis",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Mausoleum at Halicarnassus",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
},
{
"answer": "The Colossus of Rhodes",
"correct": false,
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
}
}
],
"image": "https://media.kahoot.it/34b01038-031c-4d23-b8a0-55402916586f_opt",
"imageMetadata": {
@@ -351,16 +836,39 @@
"contentType": "image/*",
"resources": "By Varios [CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0)], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/d/d6/Siete_maravillas_antiguas.jpg",
"effects": [],
"crop": {"origin": {"x": 19, "y": 0}, "target": {"x": 491, "y": 736}, "circular": false}
"crop": {
"origin": {
"x": 19,
"y": 0
},
"target": {
"x": 491,
"y": 736
},
"circular": false
}
},
"resources": "By Varios [CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0)], via Wikimedia Commons\nhttps://upload.wikimedia.org/wikipedia/commons/d/d6/Siete_maravillas_antiguas.jpg",
"video": {"id": "", "startTime": 0, "endTime": 0, "service": "youtube", "fullUrl": ""},
"video": {
"id": "",
"startTime": 0,
"endTime": 0,
"service": "youtube",
"fullUrl": ""
},
"questionFormat": 0,
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"media": []
}
],
"contentTags": {"curriculumCodes": [], "generatedCurriculumCodes": []},
"contentTags": {
"curriculumCodes": [],
"generatedCurriculumCodes": []
},
"metadata": {
"access": {
"groupRead": [
@@ -372,12 +880,20 @@
"36022fd9-43e1-4b36-9c98-a6a3b2b53038"
],
"folderGroupIds": [],
"features": ["PremiumEduContent"]
"features": [
"PremiumEduContent"
]
},
"duplicationProtection": true,
"featuredListMemberships": [
{"list": "youngfeatured", "addedAt": 1682336780289},
{"list": "featured", "addedAt": 1682336738189}
{
"list": "youngfeatured",
"addedAt": 1682336780289
},
{
"list": "featured",
"addedAt": 1682336738189
}
],
"lastEdit": {
"editorUserId": "4c1574ee-de54-40a2-be15-8d72b333afad",
@@ -392,9 +908,17 @@
},
"resources": "Nick Brundle Photography/Moment/Getty Images",
"slug": "seven-wonders-of-the-ancient-world",
"languageInfo": {"language": "en-US", "lastUpdatedOn": 1741920189202, "readAloudSupported": true},
"languageInfo": {
"language": "en-US",
"lastUpdatedOn": 1741920189202,
"readAloudSupported": true
},
"inventoryItemIds": [],
"channels": [{"id": "247c3eb4-af80-4c1f-b006-558682c7bd2f"}],
"channels": [
{
"id": "247c3eb4-af80-4c1f-b006-558682c7bd2f"
}
],
"isValid": true,
"playAsGuest": true,
"hasRestrictedContent": false,

View File

@@ -4,7 +4,7 @@ plugins {
}
android {
namespace = "dev.adriankuta.kahootquiz.model.data"
namespace = "dev.adriankuta.kahootquiz.data"
}
dependencies {

5
data/lint-baseline.xml Normal file
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,13 +1,13 @@
package dev.adriankuta.kahootquiz.model.data
package dev.adriankuta.kahootquiz.data
import dev.adriankuta.kahootquiz.core.network.retrofit.QuizApi
import dev.adriankuta.kahootquiz.data.mappers.toDomainModel
import dev.adriankuta.kahootquiz.domain.models.Quiz
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
import dev.adriankuta.kahootquiz.model.data.mappers.toDomainModel
import javax.inject.Inject
internal class QuizRepositoryImpl @Inject constructor(
private val quizApi: QuizApi
private val quizApi: QuizApi,
) : QuizRepository {
override suspend fun getQuiz(): Quiz {

View File

@@ -1,11 +1,11 @@
package dev.adriankuta.kahootquiz.model.data.di
package dev.adriankuta.kahootquiz.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.adriankuta.kahootquiz.data.QuizRepositoryImpl
import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
import dev.adriankuta.kahootquiz.model.data.QuizRepositoryImpl
import javax.inject.Singleton
@Module
@@ -15,6 +15,6 @@ internal abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindsQuizRepository(
quizRepositoryImpl: QuizRepositoryImpl
quizRepositoryImpl: QuizRepositoryImpl,
): QuizRepository
}

View File

@@ -1,7 +1,46 @@
package dev.adriankuta.kahootquiz.model.data.mappers
@file:Suppress("TooManyFunctions")
import dev.adriankuta.kahootquiz.core.network.models.*
import dev.adriankuta.kahootquiz.domain.models.*
package dev.adriankuta.kahootquiz.data.mappers
import dev.adriankuta.kahootquiz.core.network.models.AccessDto
import dev.adriankuta.kahootquiz.core.network.models.ChannelDto
import dev.adriankuta.kahootquiz.core.network.models.ChoiceDto
import dev.adriankuta.kahootquiz.core.network.models.ChoiceRangeDto
import dev.adriankuta.kahootquiz.core.network.models.ContentTagsDto
import dev.adriankuta.kahootquiz.core.network.models.CoverMetadataDto
import dev.adriankuta.kahootquiz.core.network.models.CropDto
import dev.adriankuta.kahootquiz.core.network.models.ExtractedColorDto
import dev.adriankuta.kahootquiz.core.network.models.FeaturedListMembershipDto
import dev.adriankuta.kahootquiz.core.network.models.ImageMetadataDto
import dev.adriankuta.kahootquiz.core.network.models.LanguageInfoDto
import dev.adriankuta.kahootquiz.core.network.models.LastEditDto
import dev.adriankuta.kahootquiz.core.network.models.MediaItemDto
import dev.adriankuta.kahootquiz.core.network.models.MetadataDto
import dev.adriankuta.kahootquiz.core.network.models.PointDto
import dev.adriankuta.kahootquiz.core.network.models.QuestionDto
import dev.adriankuta.kahootquiz.core.network.models.QuizResponseDto
import dev.adriankuta.kahootquiz.core.network.models.VersionMetadataDto
import dev.adriankuta.kahootquiz.core.network.models.VideoDto
import dev.adriankuta.kahootquiz.domain.models.Access
import dev.adriankuta.kahootquiz.domain.models.Channel
import dev.adriankuta.kahootquiz.domain.models.Choice
import dev.adriankuta.kahootquiz.domain.models.ChoiceRange
import dev.adriankuta.kahootquiz.domain.models.ContentTags
import dev.adriankuta.kahootquiz.domain.models.CoverMetadata
import dev.adriankuta.kahootquiz.domain.models.Crop
import dev.adriankuta.kahootquiz.domain.models.ExtractedColor
import dev.adriankuta.kahootquiz.domain.models.FeaturedListMembership
import dev.adriankuta.kahootquiz.domain.models.ImageMetadata
import dev.adriankuta.kahootquiz.domain.models.LanguageInfo
import dev.adriankuta.kahootquiz.domain.models.LastEdit
import dev.adriankuta.kahootquiz.domain.models.MediaItem
import dev.adriankuta.kahootquiz.domain.models.Metadata
import dev.adriankuta.kahootquiz.domain.models.Point
import dev.adriankuta.kahootquiz.domain.models.Question
import dev.adriankuta.kahootquiz.domain.models.Quiz
import dev.adriankuta.kahootquiz.domain.models.QuizId
import dev.adriankuta.kahootquiz.domain.models.VersionMetadata
import dev.adriankuta.kahootquiz.domain.models.Video
import kotlin.time.Duration.Companion.milliseconds
internal fun QuizResponseDto.toDomainModel(): Quiz =
@@ -34,7 +73,7 @@ internal fun QuizResponseDto.toDomainModel(): Quiz =
hasRestrictedContent = hasRestrictedContent,
type = type,
created = created,
modified = modified
modified = modified,
)
private fun CoverMetadataDto.toDomain(): CoverMetadata = CoverMetadata(
@@ -48,25 +87,25 @@ private fun CoverMetadataDto.toDomain(): CoverMetadata = CoverMetadata(
height = height,
extractedColors = extractedColors?.map { it.toDomain() },
blurhash = blurhash,
crop = crop?.toDomain()
crop = crop?.toDomain(),
)
private fun ExtractedColorDto.toDomain(): ExtractedColor = ExtractedColor(
swatch = swatch,
rgbHex = rgbHex
rgbHex = rgbHex,
)
private fun CropDto.toDomain(): Crop = Crop(
origin = origin?.toDomain(),
target = target?.toDomain(),
circular = circular
circular = circular,
)
private fun PointDto.toDomain(): Point = Point(x = x, y = y)
private fun ContentTagsDto.toDomain(): ContentTags = ContentTags(
curriculumCodes = curriculumCodes,
generatedCurriculumCodes = generatedCurriculumCodes
generatedCurriculumCodes = generatedCurriculumCodes,
)
private fun MetadataDto.toDomain(): Metadata = Metadata(
@@ -74,36 +113,36 @@ private fun MetadataDto.toDomain(): Metadata = Metadata(
duplicationProtection = duplicationProtection,
featuredListMemberships = featuredListMemberships?.map { it.toDomain() },
lastEdit = lastEdit?.toDomain(),
versionMetadata = versionMetadata?.toDomain()
versionMetadata = versionMetadata?.toDomain(),
)
private fun AccessDto.toDomain(): Access = Access(
groupRead = groupRead,
folderGroupIds = folderGroupIds,
features = features
features = features,
)
private fun FeaturedListMembershipDto.toDomain(): FeaturedListMembership = FeaturedListMembership(
list = list,
addedAt = addedAt
addedAt = addedAt,
)
private fun LastEditDto.toDomain(): LastEdit = LastEdit(
editorUserId = editorUserId,
editorUsername = editorUsername,
editTimestamp = editTimestamp
editTimestamp = editTimestamp,
)
private fun VersionMetadataDto.toDomain(): VersionMetadata = VersionMetadata(
version = version,
created = created,
creator = creator
creator = creator,
)
private fun LanguageInfoDto.toDomain(): LanguageInfo = LanguageInfo(
language = language,
lastUpdatedOn = lastUpdatedOn,
readAloudSupported = readAloudSupported
readAloudSupported = readAloudSupported,
)
private fun ChannelDto.toDomain(): Channel = Channel(id = id)
@@ -114,7 +153,7 @@ private fun QuestionDto.toDomain(): Question = Question(
time = time?.milliseconds,
points = points,
pointsMultiplier = pointsMultiplier,
choices = choices?.map { it.toDomain() },
choices = choices?.map { it.toDomain() }.orEmpty(),
layout = layout,
image = image,
imageMetadata = imageMetadata?.toDomain(),
@@ -123,13 +162,13 @@ private fun QuestionDto.toDomain(): Question = Question(
questionFormat = questionFormat,
languageInfo = languageInfo?.toDomain(),
media = media?.map { it.toDomain() },
choiceRange = choiceRange?.toDomain()
choiceRange = choiceRange?.toDomain(),
)
private fun ChoiceDto.toDomain(): Choice = Choice(
answer = answer,
correct = correct,
languageInfo = languageInfo?.toDomain()
languageInfo = languageInfo?.toDomain(),
)
private fun VideoDto.toDomain(): Video = Video(
@@ -137,7 +176,7 @@ private fun VideoDto.toDomain(): Video = Video(
startTime = startTime,
endTime = endTime,
service = service,
fullUrl = fullUrl
fullUrl = fullUrl,
)
private fun ImageMetadataDto.toDomain(): ImageMetadata = ImageMetadata(
@@ -150,7 +189,7 @@ private fun ImageMetadataDto.toDomain(): ImageMetadata = ImageMetadata(
width = width,
height = height,
effects = effects,
crop = crop?.toDomain()
crop = crop?.toDomain(),
)
private fun MediaItemDto.toDomain(): MediaItem = MediaItem(
@@ -165,7 +204,7 @@ private fun MediaItemDto.toDomain(): MediaItem = MediaItem(
resources = resources,
width = width,
height = height,
crop = crop?.toDomain()
crop = crop?.toDomain(),
)
private fun ChoiceRangeDto.toDomain(): ChoiceRange = ChoiceRange(
@@ -173,5 +212,5 @@ private fun ChoiceRangeDto.toDomain(): ChoiceRange = ChoiceRange(
end = end,
step = step,
correct = correct,
tolerance = tolerance
tolerance = tolerance,
)

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

@@ -5,5 +5,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class Access(
val groupRead: List<String>?,
val folderGroupIds: List<String>?,
val features: List<String>?
)
val features: List<String>?,
)

View File

@@ -2,4 +2,4 @@ package dev.adriankuta.kahootquiz.domain.models
// Minimal channel info
data class Channel(val id: String?)
data class Channel(val id: String?)

View File

@@ -2,6 +2,6 @@ package dev.adriankuta.kahootquiz.domain.models
data class Choice(
val answer: String?,
val correct: Boolean?,
val languageInfo: LanguageInfo?
)
val correct: Boolean,
val languageInfo: LanguageInfo? = null,
)

View File

@@ -7,5 +7,5 @@ data class ChoiceRange(
val end: Int?,
val step: Int?,
val correct: Int?,
val tolerance: Int?
)
val tolerance: Int?,
)

View File

@@ -4,5 +4,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class ContentTags(
val curriculumCodes: List<String>?,
val generatedCurriculumCodes: List<String>?
)
val generatedCurriculumCodes: List<String>?,
)

View File

@@ -13,5 +13,5 @@ data class CoverMetadata(
val height: Int?,
val extractedColors: List<ExtractedColor>?,
val blurhash: String?,
val crop: Crop?
)
val crop: Crop?,
)

View File

@@ -5,5 +5,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class Crop(
val origin: Point?,
val target: Point?,
val circular: Boolean?
)
val circular: Boolean?,
)

View File

@@ -4,5 +4,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class ExtractedColor(
val swatch: String?,
val rgbHex: String?
)
val rgbHex: String?,
)

View File

@@ -4,5 +4,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class FeaturedListMembership(
val list: String?,
val addedAt: Long?
)
val addedAt: Long?,
)

View File

@@ -12,5 +12,5 @@ data class ImageMetadata(
val width: Int? = null,
val height: Int? = null,
val effects: List<String>? = null,
val crop: Crop? = null
)
val crop: Crop? = null,
)

View File

@@ -5,5 +5,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class LanguageInfo(
val language: String?,
val lastUpdatedOn: Long?,
val readAloudSupported: Boolean?
)
val readAloudSupported: Boolean?,
)

View File

@@ -5,5 +5,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class LastEdit(
val editorUserId: String?,
val editorUsername: String?,
val editTimestamp: Long?
)
val editTimestamp: Long?,
)

View File

@@ -14,5 +14,5 @@ data class MediaItem(
val resources: String? = null,
val width: Int? = null,
val height: Int? = null,
val crop: Crop? = null
)
val crop: Crop? = null,
)

View File

@@ -7,5 +7,5 @@ data class Metadata(
val duplicationProtection: Boolean?,
val featuredListMemberships: List<FeaturedListMembership>?,
val lastEdit: LastEdit?,
val versionMetadata: VersionMetadata?
)
val versionMetadata: VersionMetadata?,
)

View File

@@ -4,5 +4,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class Point(
val x: Int?,
val y: Int?
)
val y: Int?,
)

View File

@@ -8,16 +8,16 @@ data class Question(
val type: String?,
val question: String?,
val time: Duration?,
val points: Boolean?,
val points: Boolean? = null,
val pointsMultiplier: Int?,
val choices: List<Choice>?,
val layout: String?,
val image: String?,
val choices: List<Choice>,
val layout: String? = null,
val image: String? = null,
val imageMetadata: ImageMetadata?,
val resources: String?,
val video: Video?,
val questionFormat: Int?,
val languageInfo: LanguageInfo?,
val media: List<MediaItem>?,
val choiceRange: ChoiceRange?
)
val resources: String? = null,
val video: Video? = null,
val questionFormat: Int? = null,
val languageInfo: LanguageInfo? = null,
val media: List<MediaItem>? = null,
val choiceRange: ChoiceRange? = null,
)

View File

@@ -31,5 +31,5 @@ data class Quiz(
val hasRestrictedContent: Boolean?,
val type: String?,
val created: Long?,
val modified: Long?
)
val modified: Long?,
)

View File

@@ -5,5 +5,5 @@ package dev.adriankuta.kahootquiz.domain.models
data class VersionMetadata(
val version: Int?,
val created: Long?,
val creator: String?
)
val creator: String?,
)

View File

@@ -5,5 +5,5 @@ data class Video(
val startTime: Int?,
val endTime: Int?,
val service: String?,
val fullUrl: String?
)
val fullUrl: String?,
)

View File

@@ -5,7 +5,7 @@ import dev.adriankuta.kahootquiz.domain.repositories.QuizRepository
import javax.inject.Inject
class GetQuizUseCase @Inject constructor(
private val quizRepository: QuizRepository
private val quizRepository: QuizRepository,
) {
suspend operator fun invoke(): Quiz {

View File

@@ -1,5 +1,4 @@
[versions]
retrofit = "3.0.0"
targetSdk = "36"
compileSdk = "36"
minSdk = "23"
@@ -21,6 +20,8 @@ animation = "1.9.0"
appUpdateKtx = "2.1.0"
appcompat = "1.7.1"
billing = "8.0.0"
coilCompose = "3.3.0"
coilNetworkOkhttp = "3.3.0"
coreTest = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/test
datastorePreferences = "1.1.7" # https://developer.android.com/topic/libraries/architecture/datastore#preferences-datastore-dependencies
datetime = "0.7.1" # https://github.com/Kotlin/kotlinx-datetime/releases
@@ -46,6 +47,7 @@ material = "1.12.0"
materialIconsExtended = "1.7.8"
mockk = "1.14.5" # https://github.com/mockk/mockk/releases
playServicesAds = "24.5.0"
retrofit = "3.0.0"
reviewKtx = "2.0.2"
room = "2.7.2"
secrets = "2.0.1"
@@ -94,6 +96,8 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio
app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
detekt-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" }
detekt-ktlint = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
dotlottie-android = { module = "com.github.LottieFiles:dotlottie-android", version.ref = "dotlottieAndroid" }

View File

@@ -25,7 +25,8 @@ rootProject.name = "KahootQuiz"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app")
include(":core:designsystem")
include(":core:network")
include(":data")
include(":domain")
include(":model:data")
include(":ui:quiz")

View File

@@ -9,7 +9,11 @@ android {
}
dependencies {
implementation(projects.core.designsystem)
implementation(projects.domain)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.timber)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}

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
formatting:
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,33 +1,216 @@
package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.adriankuta.kahootquiz.core.designsystem.Grey
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
import dev.adriankuta.kahootquiz.domain.models.Choice
import dev.adriankuta.kahootquiz.domain.models.Question
import dev.adriankuta.kahootquiz.ui.quiz.components.AnswerFeedbackBanner
import dev.adriankuta.kahootquiz.ui.quiz.components.Choices
import dev.adriankuta.kahootquiz.ui.quiz.components.QuestionContent
import dev.adriankuta.kahootquiz.ui.quiz.components.TimerBar
import dev.adriankuta.kahootquiz.ui.quiz.components.Toolbar
import kotlin.time.Duration.Companion.seconds
@Composable
fun QuizScreen(
modifier: Modifier = Modifier,
viewModel: QuizScreenViewModel = hiltViewModel()
viewModel: QuizScreenViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
QuizScreen(
uiState = uiState,
modifier = modifier
onSelect = viewModel::onChoiceSelected,
onContinue = viewModel::onContinue,
modifier = modifier.fillMaxSize(),
)
}
@Composable
private fun QuizScreen(
uiState: QuizUiState,
uiState: ScreenUiState,
onSelect: (Int) -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
Text(uiState.quiz?.id?.value ?: "")
Box(modifier.fillMaxSize()) {
when (uiState) {
ScreenUiState.Loading -> QuizScreenLoading()
is ScreenUiState.Success -> QuizScreenSuccess(
uiState = uiState,
onSelect = onSelect,
onContinue = onContinue,
)
}
}
}
@Composable
private fun QuizScreenLoading(
modifier: Modifier = Modifier,
) {
CircularProgressIndicator(
modifier = modifier,
)
}
@Composable
@Suppress("LongMethod")
private fun QuizScreenSuccess(
uiState: ScreenUiState.Success,
onSelect: (Int) -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth(),
) {
Box(
modifier = Modifier
.height(72.dp),
) {
Toolbar(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
currentQuestionIndex = uiState.currentQuestionIndex,
totalQuestions = uiState.totalQuestions,
)
uiState.isAnswerCorrect?.let { isCorrect ->
AnswerFeedbackBanner(
isCorrect = isCorrect,
)
}
}
QuestionContent(
question = uiState.currentQuestion,
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight(0.5f),
)
Spacer(Modifier.height(8.dp))
Choices(
choices = uiState.currentQuestion.choices,
selectedChoiceIndex = uiState.selectedChoiceIndex,
onSelect = onSelect,
modifier = Modifier
.padding(8.dp)
.weight(1f),
)
// Timer below choices
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) {
TimerBar(
totalSeconds = uiState.timerState.totalTimeSeconds,
remainingSeconds = uiState.timerState.remainingTimeSeconds,
modifier = Modifier.padding(8.dp),
)
} else {
Box(
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = onContinue,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = Grey,
contentColor = Color.Black,
),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.align(Alignment.Center),
) {
Text(
text = stringResource(R.string.continue_text),
)
}
}
}
}
}
@Preview
@Composable
private fun QuizScreenPreview() {
KahootQuizTheme {
val sampleQuestion = Question(
type = "quiz",
image = "", // Add a sample image URL or leave empty
question = "What is the capital of France?",
choices = listOf(
Choice(answer = "Berlin", correct = false),
Choice(answer = "Madrid", correct = false),
Choice(answer = "Paris", correct = true),
Choice(answer = "Rome", correct = false),
),
pointsMultiplier = 1,
time = 30.seconds,
questionFormat = 0,
imageMetadata = null,
)
QuizScreen(
uiState = ScreenUiState.Success(
currentQuestion = sampleQuestion,
selectedChoiceIndex = null,
totalQuestions = 12,
),
onSelect = {},
onContinue = {},
)
}
}
@Preview
@Composable
private fun QuizScreenRevealedAnswerPreview() {
KahootQuizTheme {
val sampleQuestion = Question(
type = "quiz",
image = "", // Add a sample image URL or leave empty
question = "What is the capital of France?",
choices = listOf(
Choice(answer = "Berlin", correct = false),
Choice(answer = "Madrid", correct = false),
Choice(answer = "Paris", correct = true),
Choice(answer = "Rome", correct = false),
),
pointsMultiplier = 1,
time = 30.seconds,
questionFormat = 0,
imageMetadata = null,
)
QuizScreen(
uiState = ScreenUiState.Success(
currentQuestion = sampleQuestion,
selectedChoiceIndex = 1,
totalQuestions = 12,
),
onSelect = {},
onContinue = {},
)
}
}

View File

@@ -3,29 +3,184 @@ package dev.adriankuta.kahootquiz.ui.quiz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.kahootquiz.domain.models.Question
import dev.adriankuta.kahootquiz.domain.models.Quiz
import dev.adriankuta.kahootquiz.domain.usecases.GetQuizUseCase
import dev.adriankuta.kahootquiz.ui.quiz.utils.Result
import dev.adriankuta.kahootquiz.ui.quiz.utils.asResult
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class QuizScreenViewModel @Inject constructor(
private val getQuizUseCase: GetQuizUseCase
private val getQuizUseCase: GetQuizUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(QuizUiState())
val uiState: StateFlow<QuizUiState> = _uiState.asStateFlow()
private val quiz: StateFlow<QuizUiState> = flow {
emit(QuizUiState.Success(getQuizUseCase()))
}
.asResult()
.map { quizResult ->
when (quizResult) {
is Result.Error -> QuizUiState.Loading // Todo error handling not implemented on UI
Result.Loading -> QuizUiState.Loading
is Result.Success -> quizResult.data
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = QuizUiState.Loading,
)
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
private val _remainingTimeSeconds = MutableStateFlow(0)
private val _currentQuestionIndex = MutableStateFlow(0)
private var timerJob: Job? = null
init {
// Start timer when the first question is displayed (on initial quiz load)
viewModelScope.launch {
_uiState.value = QuizUiState(getQuizUseCase())
quiz.collect { quizState ->
if (quizState is QuizUiState.Success) {
// Start only if timer hasn't been started yet and we are on the first question
if (timerJob == null && _currentQuestionIndex.value == 0) {
val firstQuestionTime =
quizState.quiz.questions.getOrNull(0)?.time?.inWholeSeconds?.toInt()
startCountdown(firstQuestionTime)
}
}
}
}
}
val uiState: StateFlow<ScreenUiState> = screenUiState(
quizFlow = quiz,
selectedChoiceIndexFlow = _selectedChoiceIndex,
remainingTimeSecondsFlow = _remainingTimeSeconds,
currentQuestionIndexFlow = _currentQuestionIndex,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ScreenUiState.Loading,
)
fun onChoiceSelected(index: Int) {
timerJob?.cancel()
timerJob = null
_selectedChoiceIndex.value = index
}
fun onContinue() {
val quizState = quiz.value
if (quizState is QuizUiState.Success) {
val total = quizState.quiz.questions.size
val current = _currentQuestionIndex.value
val nextIndex = current + 1
if (nextIndex < total) {
_selectedChoiceIndex.value = null
_currentQuestionIndex.value = nextIndex
val nextQuestionTime =
quizState.quiz.questions[nextIndex].time?.inWholeSeconds?.toInt()
startCountdown(nextQuestionTime)
} else {
// Last question reached: stop timer and keep state (could navigate to results in the future)
timerJob?.cancel()
timerJob = null
_remainingTimeSeconds.value = 0
}
}
}
private fun startCountdown(totalSeconds: Int?) {
timerJob?.cancel()
if (totalSeconds == null || totalSeconds <= 0) {
_remainingTimeSeconds.value = 0
timerJob = null
return
}
_remainingTimeSeconds.value = totalSeconds
timerJob = viewModelScope.launch {
var remaining = totalSeconds
while (remaining > 0) {
delay(1.seconds)
remaining -= 1
_remainingTimeSeconds.value = remaining
}
// Time out: reveal answers without a selection
if (_selectedChoiceIndex.value == null) {
_selectedChoiceIndex.value = -1
}
timerJob = null
}
}
}
data class QuizUiState(
val quiz: Quiz? = null
)
private fun screenUiState(
quizFlow: StateFlow<QuizUiState>,
selectedChoiceIndexFlow: Flow<Int?>,
remainingTimeSecondsFlow: Flow<Int>,
currentQuestionIndexFlow: Flow<Int>,
): Flow<ScreenUiState> = combine(
quizFlow,
selectedChoiceIndexFlow,
remainingTimeSecondsFlow,
currentQuestionIndexFlow,
) { quizState, selectedChoiceIndex, remainingTimeSeconds, currentQuestionIndex ->
when (quizState) {
QuizUiState.Loading -> ScreenUiState.Loading
is QuizUiState.Success -> {
val currentQuestion = quizState.quiz.questions[currentQuestionIndex]
val isAnswerCorrect = selectedChoiceIndex?.let { idx ->
currentQuestion.choices?.getOrNull(idx)?.correct == true
}
ScreenUiState.Success(
currentQuestion = currentQuestion,
selectedChoiceIndex = selectedChoiceIndex,
currentQuestionIndex = currentQuestionIndex,
totalQuestions = quizState.quiz.questions.size,
timerState = TimerState(
remainingTimeSeconds = remainingTimeSeconds,
totalTimeSeconds = currentQuestion.time?.inWholeSeconds?.toInt() ?: 0,
),
isAnswerCorrect = isAnswerCorrect,
)
}
}
}
sealed interface QuizUiState {
data object Loading : QuizUiState
data class Success(
val quiz: Quiz,
) : QuizUiState
}
sealed interface ScreenUiState {
data object Loading : ScreenUiState
data class Success(
val currentQuestion: Question,
val selectedChoiceIndex: Int? = null,
val currentQuestionIndex: Int = 0,
val totalQuestions: Int = 0,
val timerState: TimerState = TimerState(),
val isAnswerCorrect: Boolean? = null,
) : ScreenUiState
}
data class TimerState(
val remainingTimeSeconds: Int = 0,
val totalTimeSeconds: Int = 0,
)

View File

@@ -0,0 +1,176 @@
@file:OptIn(ExperimentalLayoutApi::class)
package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.adriankuta.kahootquiz.core.designsystem.Blue2
import dev.adriankuta.kahootquiz.core.designsystem.Green
import dev.adriankuta.kahootquiz.core.designsystem.Green2
import dev.adriankuta.kahootquiz.core.designsystem.Pink
import dev.adriankuta.kahootquiz.core.designsystem.Red
import dev.adriankuta.kahootquiz.core.designsystem.Red2
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo
import dev.adriankuta.kahootquiz.domain.models.Choice
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
@Composable
fun Choices(
choices: List<Choice>,
onSelect: (Int) -> Unit,
selectedChoiceIndex: Int?,
modifier: Modifier = Modifier,
) {
EvenGrid(
items = choices,
columns = 2,
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { choice, index ->
ChoiceItem(
choice = choice,
index = index,
selectedChoiceIndex = selectedChoiceIndex,
onClick = { onSelect(index) },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
}
@Composable
private fun ChoiceItem(
choice: Choice,
onClick: () -> Unit,
index: Int,
selectedChoiceIndex: Int?,
modifier: Modifier = Modifier,
) {
if (selectedChoiceIndex != null) {
ChoiceItemRevealed(
choice = choice,
index = index,
isSelected = selectedChoiceIndex == index,
modifier = modifier,
)
} else {
ChoiceItemDefault(
choice = choice,
index = index,
onClick = onClick,
modifier = modifier,
)
}
}
@Composable
private fun ChoiceItemDefault(
choice: Choice,
index: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = when (index) {
0 -> Red2
1 -> Blue2
2 -> Yellow3
3 -> Green2
else -> Color.Gray
}
val icon = when (index) {
0 -> DesignR.drawable.ic_triangle
1 -> DesignR.drawable.ic_diamond
2 -> DesignR.drawable.ic_circle
else -> DesignR.drawable.ic_square
}
Box(
modifier = modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
.clickable(
onClick = onClick,
),
) {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(32.dp),
)
Text(
text = choice.answer ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
color = contrastiveTo(backgroundColor),
)
}
}
@Composable
private fun ChoiceItemRevealed(
choice: Choice,
index: Int,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundColor = when {
isSelected && !choice.correct -> Red
choice.correct -> Green
else -> Pink
}
val icon = if (choice.correct) {
DesignR.drawable.ic_correct
} else {
DesignR.drawable.ic_wrong
}
val alignment = if (index % 2 == 0) {
Alignment.TopStart
} else {
Alignment.TopEnd
}
Box(
modifier = modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp)),
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier
.align(alignment)
.offset(
x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp,
y = (-8).dp,
),
)
Text(
text = choice.answer ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
color = contrastiveTo(backgroundColor),
)
}
}

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

@@ -0,0 +1,60 @@
package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import coil3.compose.AsyncImage
import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString
import dev.adriankuta.kahootquiz.domain.models.Question
@Composable
fun QuestionContent(
question: Question,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
AsyncImage(
model = question.image,
contentDescription = question.imageMetadata?.altText,
contentScale = ContentScale.Fit,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterHorizontally)
.clip(shape = RoundedCornerShape(4.dp)),
)
Spacer(Modifier.height(16.dp))
val questionText = androidx.compose.runtime.remember(question.question) {
HtmlCompat.fromHtml(
question.question ?: "",
HtmlCompat.FROM_HTML_MODE_COMPACT,
).toAnnotatedString()
}
Text(
text = questionText,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(
color = Color.White,
shape = RoundedCornerShape(4.dp),
)
.padding(horizontal = 8.dp, vertical = 16.dp),
)
}
}

View File

@@ -0,0 +1,50 @@
package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.adriankuta.kahootquiz.core.designsystem.Purple
@Composable
fun TimerBar(
totalSeconds: Int,
remainingSeconds: Int,
modifier: Modifier = Modifier,
) {
val target =
if (totalSeconds <= 0) 0f else (remainingSeconds.toFloat() / totalSeconds).coerceIn(0f, 1f)
val progress: Float by animateFloatAsState(
targetValue = target,
label = "Timer",
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
Box(
modifier = modifier
.fillMaxWidth(progress)
.background(
color = Purple,
shape = RoundedCornerShape(percent = 50),
),
) {
Text(
text = "$remainingSeconds",
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp),
color = Color.White,
)
}
}

View File

@@ -0,0 +1,63 @@
package dev.adriankuta.kahootquiz.ui.quiz.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.adriankuta.kahootquiz.core.designsystem.Grey
import dev.adriankuta.kahootquiz.ui.quiz.R
import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR
@Composable
fun Toolbar(
modifier: Modifier = Modifier,
currentQuestionIndex: Int = 0,
totalQuestions: Int = 0,
) {
Box(
modifier = modifier,
) {
Text(
text = "${currentQuestionIndex + 1}/$totalQuestions",
modifier = Modifier
.align(Alignment.CenterStart)
.background(
color = Grey,
shape = RoundedCornerShape(percent = 50),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
Row(
modifier = Modifier
.align(Alignment.Center)
.background(
color = Grey,
shape = RoundedCornerShape(percent = 50),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Image(
painter = painterResource(id = DesignR.drawable.ic_type),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(R.string.quiz),
)
}
}
}

View File

@@ -1,3 +1,5 @@
@file:Suppress("MatchingDeclarationName")
package dev.adriankuta.kahootquiz.ui.quiz.navigation
import androidx.navigation.NavGraphBuilder
@@ -12,4 +14,4 @@ fun NavGraphBuilder.quizScreen() {
composable<QuizRoute> {
QuizScreen()
}
}
}

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

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="quiz">Quiz</string>
<string name="continue_text">Continue</string>
<string name="correct">Correct</string>
<string name="wrong">Wrong</string>
</resources>