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