Compare commits

..

7 Commits

Author SHA1 Message Date
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
8 changed files with 619 additions and 279 deletions

View File

@@ -19,11 +19,12 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val Grey = Color(0xFFFAFAFA)
val Pink = Color(0xFFFF99AA)
val Red = Color(0xFFFF3355)
val Red2 = Color(0xFFE21B3C)
val Blue2 = Color(0xFF1368CE)
val Yellow3 = Color(0xFFD89E00)
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

@@ -1,26 +1,19 @@
package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.animation.animateContentSize
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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
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
@@ -30,26 +23,18 @@ 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.adriankuta.kahootquiz.core.designsystem.Blue2
import dev.adriankuta.kahootquiz.core.designsystem.Green
import dev.adriankuta.kahootquiz.core.designsystem.Green2
import dev.adriankuta.kahootquiz.core.designsystem.Grey
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
import dev.adriankuta.kahootquiz.core.designsystem.Pink
import dev.adriankuta.kahootquiz.core.designsystem.Red
import dev.adriankuta.kahootquiz.core.designsystem.Red2
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo
import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString
import dev.adriankuta.kahootquiz.domain.models.Choice
import dev.adriankuta.kahootquiz.domain.models.Question
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
@@ -64,14 +49,16 @@ fun QuizScreen(
QuizScreen(
uiState = uiState,
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,
) {
Box(modifier.fillMaxSize()) {
@@ -81,244 +68,134 @@ private fun QuizScreen(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Column(
modifier = Modifier
.fillMaxWidth(),
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
private fun QuizScreenSuccess(
uiState: ScreenUiState.Success,
onSelect: (Int) -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxWidth()
.animateContentSize(),
) {
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 ?: return,
modifier = Modifier.padding(horizontal = 8.dp),
question = uiState.currentQuestion,
modifier = Modifier
.padding(horizontal = 8.dp)
.animateItem(),
)
Spacer(Modifier.height(8.dp))
Choices(
choices = uiState.currentQuestion.choices ?: emptyList(), // TODO remove empty list
answer = uiState.answer,
onSelect = onSelect,
)
}
}
}
@Composable
private fun Toolbar(
modifier: Modifier = Modifier,
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,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "2/24",
modifier = Modifier
.align(Alignment.CenterStart)
.background(
color = Grey,
shape = RoundedCornerShape(60.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
Row(
modifier = Modifier
.align(Alignment.Center)
.background(
color = Grey,
shape = RoundedCornerShape(60.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Image(
painter = painterResource(id = DesignR.drawable.ic_type),
contentDescription = "",
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(R.string.quiz),
)
}
}
}
@Composable
private fun QuestionContent(
question: Question,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
AsyncImage(
model = question.image,
contentDescription = question.imageMetadata?.altText,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 200.dp),
)
Spacer(Modifier.height(16.dp))
Text(
text = HtmlCompat.fromHtml(
question.question ?: "",
HtmlCompat.FROM_HTML_MODE_COMPACT,
).toAnnotatedString(),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(
color = Color.White,
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),
)
.padding(horizontal = 8.dp, vertical = 16.dp),
)
}
}
}
}
@Composable
private fun Choices(
choices: List<Choice>,
private fun LazyListScope.choices(
uiState: ScreenUiState.Success,
onSelect: (Int) -> Unit,
answer: AnswerUiState?,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
itemsIndexed(choices) { index, choice ->
ChoiceItem(
choice = choice,
index = index,
answer = answer,
onClick = { onSelect(index) },
uiState.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),
)
}
}
}
@Composable
private fun ChoiceItem(
choice: Choice,
onClick: () -> Unit,
index: Int,
answer: AnswerUiState?,
) {
if (answer != null) {
ChoiceItemRevealed(
choice = choice,
index = index,
isSelected = answer.selectedChoiceIndex == index,
)
} else {
ChoiceItemDefault(
choice = choice,
index = index,
onClick = onClick,
)
}
}
@Composable
private fun ChoiceItemDefault(
choice: Choice,
index: Int,
onClick: () -> Unit,
) {
val backgroundColor = when (index) {
0 -> Red2
1 -> Blue2
2 -> Yellow3
3 -> Green2
else -> Color.Gray
}
// TODO Add icons
val icon = when (index) {
0 -> DesignR.drawable.ic_triangle
1 -> DesignR.drawable.ic_diamond
2 -> DesignR.drawable.ic_circle
else -> DesignR.drawable.ic_square
}
Box(
modifier = Modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
.height(100.dp)
.clickable(
onClick = onClick,
),
) {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(32.dp),
)
Text(
text = choice.answer ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
color = contrastiveTo(backgroundColor),
)
}
}
@Composable
private fun ChoiceItemRevealed(
choice: Choice,
index: Int,
isSelected: Boolean,
) {
val backgroundColor = when {
isSelected && !choice.correct -> Red
choice.correct -> Green
else -> Pink
}
val icon = if (choice.correct) {
DesignR.drawable.ic_correct
} else {
DesignR.drawable.ic_wrong
}
val alignment = if (index % 2 == 0) {
Alignment.TopStart
} else {
Alignment.TopEnd
}
Box(
modifier = Modifier
.background(backgroundColor, shape = RoundedCornerShape(4.dp))
.height(100.dp),
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier
.align(alignment)
.offset(
x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp,
(-8).dp,
),
)
Text(
text = choice.answer ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
color = contrastiveTo(backgroundColor),
)
}
}
@Preview
@Composable
@@ -340,10 +217,13 @@ private fun QuizScreenPreview() {
imageMetadata = null,
)
QuizScreen(
uiState = QuizUiState(
uiState = ScreenUiState.Success(
currentQuestion = sampleQuestion,
selectedChoiceIndex = null,
totalQuestions = 12,
),
onSelect = {},
onContinue = {},
)
}
}
@@ -368,13 +248,13 @@ private fun QuizScreenRevealedAnswerPreview() {
imageMetadata = null,
)
QuizScreen(
uiState = QuizUiState(
uiState = ScreenUiState.Success(
currentQuestion = sampleQuestion,
answer = AnswerUiState(
selectedChoiceIndex = 1,
),
totalQuestions = 12,
),
onSelect = {},
onContinue = {},
)
}
}

View File

@@ -4,44 +4,154 @@ 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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class QuizScreenViewModel @Inject constructor(
private val getQuizUseCase: GetQuizUseCase,
) : ViewModel() {
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
val uiState: StateFlow<QuizUiState> = combine(
suspend { getQuizUseCase() }.asFlow(),
_selectedChoiceIndex,
) { quiz, selectedChoiceIndex ->
QuizUiState(
currentQuestion = quiz.questions.first(),
answer = selectedChoiceIndex?.let { AnswerUiState(it) },
)
}.stateIn(
private val quiz: StateFlow<QuizUiState> = flow {
emit(QuizUiState.Success(getQuizUseCase()))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = QuizUiState(),
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 {
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> = 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,
),
)
}
}
}
.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
}
}
}
data class QuizUiState(
val currentQuestion: Question? = null,
val answer: AnswerUiState? = null,
)
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(1000)
remaining -= 1
_remainingTimeSeconds.value = remaining
}
// Time out: reveal answers without a selection
if (_selectedChoiceIndex.value == null) {
_selectedChoiceIndex.value = -1
}
timerJob = null
}
}
}
data class AnswerUiState(
val selectedChoiceIndex: Int,
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? = null,
val selectedChoiceIndex: Int? = null,
val currentQuestionIndex: Int = 0,
val totalQuestions: Int = 0,
val timerState: TimerState = TimerState(),
) : ScreenUiState
}
data class TimerState(
val remainingTimeSeconds: Int = 0,
val totalTimeSeconds: Int = 0,
)

View File

@@ -0,0 +1,175 @@
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.FlowRow
import androidx.compose.foundation.layout.height
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,
) {
FlowRow(
maxItemsInEachRow = 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),
)
}
}
}
@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))
.height(100.dp)
.clickable(
onClick = onClick,
),
) {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(32.dp),
)
Text(
text = choice.answer ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
color = contrastiveTo(backgroundColor),
)
}
}
@Composable
private fun ChoiceItemRevealed(
choice: Choice,
index: Int,
isSelected: Boolean,
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))
.height(100.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,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.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.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.FillWidth,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 200.dp)
.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.coerceIn(0f, 1f))
.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,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="quiz">Quiz</string>
<string name="continue_text">Continue</string>
</resources>