From d2fce7e7b9a41f6bc170969e58cc77fbfd9c74b8 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Thu, 4 Sep 2025 17:51:18 +0200 Subject: [PATCH] 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. --- .../kahootquiz/ui/quiz/QuizScreen.kt | 163 +++++++++++------- .../kahootquiz/ui/quiz/QuizScreenViewModel.kt | 136 ++++++++++----- 2 files changed, 194 insertions(+), 105 deletions(-) diff --git a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt index 2b49d52..a50fc29 100644 --- a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt +++ b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreen.kt @@ -1,5 +1,6 @@ package dev.adriankuta.kahootquiz.ui.quiz +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -9,7 +10,7 @@ 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.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,17 +21,17 @@ 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.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.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -71,14 +72,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()) { @@ -88,50 +91,75 @@ private fun QuizScreen( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - Column( - modifier = Modifier - .fillMaxWidth(), - ) { - Toolbar( + when (uiState) { + ScreenUiState.Loading -> CircularProgressIndicator() + is ScreenUiState.Success -> LazyColumn( modifier = Modifier .fillMaxWidth() - .height(72.dp) - .padding(8.dp), - currentQuestionIndex = uiState.currentQuestionIndex, - totalQuestions = uiState.totalQuestions, - ) - QuestionContent( - question = uiState.currentQuestion ?: return, - modifier = Modifier.padding(horizontal = 8.dp), - ) - Spacer(Modifier.height(8.dp)) - Choices( - choices = uiState.currentQuestion.choices ?: emptyList(), // TODO remove empty list - answer = uiState.answer, - onSelect = onSelect, - ) - // Timer below choices - if (uiState.answer == null) { - TimerBar( - totalSeconds = uiState.totalTimeSeconds, - remainingSeconds = uiState.remainingTimeSeconds, - modifier = Modifier.padding(8.dp), - ) - } else { - FilledTonalButton( - onClick = {}, - modifier = Modifier.align(Alignment.CenterHorizontally), - colors = ButtonDefaults.filledTonalButtonColors().copy( - containerColor = Grey, - contentColor = Color.Black - ), - shape = RoundedCornerShape(4.dp), - ) { - Text( - text = stringResource(R.string.continue_text), + .animateContentSize(), + ) { + item { + Toolbar( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(8.dp), + currentQuestionIndex = uiState.currentQuestionIndex, + totalQuestions = uiState.totalQuestions, ) } + item { + QuestionContent( + question = uiState.currentQuestion ?: return@item, + modifier = Modifier + .padding(horizontal = 8.dp) + .animateItem(), + ) + Spacer(Modifier.height(8.dp)) + } + + item { + Choices( + choices = uiState.currentQuestion?.choices + ?: emptyList(), // TODO remove empty list + selectedChoiceIndex = uiState.selectedChoiceIndex, + onSelect = onSelect, + modifier = Modifier.padding(8.dp), + ) + } + + // Timer below choices + if (uiState.selectedChoiceIndex == null) { + item { + TimerBar( + totalSeconds = uiState.timerState.totalTimeSeconds, + remainingSeconds = uiState.timerState.remainingTimeSeconds, + modifier = Modifier.padding(8.dp), + ) + } + } else { + item { + 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), + ) + } + } + } + } } + } } } @@ -192,7 +220,8 @@ private fun QuestionContent( contentScale = ContentScale.FillWidth, modifier = Modifier .fillMaxWidth() - .heightIn(min = 200.dp), + .heightIn(min = 200.dp) + .clip(shape = RoundedCornerShape(4.dp)), ) Spacer(Modifier.height(16.dp)) Text( @@ -216,22 +245,22 @@ private fun QuestionContent( private fun Choices( choices: List, onSelect: (Int) -> Unit, - answer: AnswerUiState?, + selectedChoiceIndex: Int?, modifier: Modifier = Modifier, ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(8.dp), + FlowRow( + maxItemsInEachRow = 2, + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, ) { - itemsIndexed(choices) { index, choice -> + choices.forEachIndexed { index, choice -> ChoiceItem( choice = choice, index = index, - answer = answer, + selectedChoiceIndex = selectedChoiceIndex, onClick = { onSelect(index) }, + modifier = Modifier.weight(1f), ) } } @@ -242,19 +271,22 @@ private fun ChoiceItem( choice: Choice, onClick: () -> Unit, index: Int, - answer: AnswerUiState?, + selectedChoiceIndex: Int?, + modifier: Modifier = Modifier, ) { - if (answer != null) { + if (selectedChoiceIndex != null) { ChoiceItemRevealed( choice = choice, index = index, - isSelected = answer.selectedChoiceIndex == index, + isSelected = selectedChoiceIndex == index, + modifier = modifier, ) } else { ChoiceItemDefault( choice = choice, index = index, onClick = onClick, + modifier = modifier, ) } } @@ -264,6 +296,7 @@ private fun ChoiceItemDefault( choice: Choice, index: Int, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val backgroundColor = when (index) { 0 -> Red2 @@ -281,7 +314,7 @@ private fun ChoiceItemDefault( else -> DesignR.drawable.ic_square } Box( - modifier = Modifier + modifier = modifier .background(backgroundColor, shape = RoundedCornerShape(4.dp)) .height(100.dp) .clickable( @@ -309,6 +342,7 @@ private fun ChoiceItemRevealed( choice: Choice, index: Int, isSelected: Boolean, + modifier: Modifier = Modifier, ) { val backgroundColor = when { isSelected && !choice.correct -> Red @@ -329,7 +363,7 @@ private fun ChoiceItemRevealed( } Box( - modifier = Modifier + modifier = modifier .background(backgroundColor, shape = RoundedCornerShape(4.dp)) .height(100.dp), ) { @@ -404,13 +438,13 @@ private fun QuizScreenPreview() { imageMetadata = null, ) QuizScreen( - uiState = QuizUiState( + uiState = ScreenUiState.Success( currentQuestion = sampleQuestion, + selectedChoiceIndex = null, totalQuestions = 12, - totalTimeSeconds = 30, - remainingTimeSeconds = 10, ), onSelect = {}, + onContinue = {}, ) } } @@ -435,16 +469,13 @@ private fun QuizScreenRevealedAnswerPreview() { imageMetadata = null, ) QuizScreen( - uiState = QuizUiState( + uiState = ScreenUiState.Success( currentQuestion = sampleQuestion, - answer = AnswerUiState( - selectedChoiceIndex = 1, - ), + selectedChoiceIndex = 1, totalQuestions = 12, - totalTimeSeconds = 30, - remainingTimeSeconds = 10, ), onSelect = {}, + onContinue = {}, ) } } diff --git a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt index 19b5fc6..f2aa823 100644 --- a/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt +++ b/ui/quiz/src/main/kotlin/dev/adriankuta/kahootquiz/ui/quiz/QuizScreenViewModel.kt @@ -4,58 +4,106 @@ 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.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.delay import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @HiltViewModel class QuizScreenViewModel @Inject constructor( private val getQuizUseCase: GetQuizUseCase, ) : ViewModel() { - private val _selectedChoiceIndex = MutableStateFlow(null) - private val _remainingTimeSeconds = MutableStateFlow(null) - private var timerJob: kotlinx.coroutines.Job? = null - val uiState: StateFlow = combine( - suspend { getQuizUseCase() }.asFlow(), + private val quiz: StateFlow = flow { + emit(QuizUiState.Success(getQuizUseCase())) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = QuizUiState.Loading, + ) + private val _selectedChoiceIndex = MutableStateFlow(null) + private val _remainingTimeSeconds = MutableStateFlow(-1) + 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 (_remainingTimeSeconds.value == -1 && _currentQuestionIndex.value == 0) { + val firstQuestionTime = quizState.quiz.questions.getOrNull(0)?.time?.inWholeSeconds?.toInt() + startCountdown(firstQuestionTime) + } + } + } + } + } + + val uiState: StateFlow = combine( + quiz, _selectedChoiceIndex, _remainingTimeSeconds, - ) { quiz, selectedChoiceIndex, remaining -> - val currentQuestion = quiz.questions.first() - val totalSeconds = (currentQuestion.time ?: 30.seconds).inWholeSeconds.toInt() - QuizUiState( - currentQuestion = currentQuestion, - answer = selectedChoiceIndex?.let { AnswerUiState(it) }, - currentQuestionIndex = 0, - totalQuestions = quiz.questions.size, - totalTimeSeconds = totalSeconds, - remainingTimeSeconds = remaining ?: totalSeconds, + _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, ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = QuizUiState(), - ) fun onChoiceSelected(index: Int) { timerJob?.cancel() _selectedChoiceIndex.value = index } - init { - // Start countdown for the first question - viewModelScope.launch { - val quiz = getQuizUseCase() - val totalSeconds = quiz.questions.first().time?.inWholeSeconds?.toInt() - startCountdown(totalSeconds) + 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() + } } } @@ -78,15 +126,25 @@ class QuizScreenViewModel @Inject constructor( } } -data class QuizUiState( - val currentQuestion: Question? = null, - val answer: AnswerUiState? = null, - val currentQuestionIndex: Int = 0, - val totalQuestions: Int = 0, - val totalTimeSeconds: Int = -1, - val remainingTimeSeconds: Int = -1, -) +sealed interface QuizUiState { + data object Loading : QuizUiState + data class Success( + val quiz: Quiz, + ) : QuizUiState +} -data class AnswerUiState( - val selectedChoiceIndex: Int, +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, )