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.
This commit is contained in:
2025-09-04 17:51:18 +02:00
parent 41fd729271
commit d2fce7e7b9
2 changed files with 194 additions and 105 deletions

View File

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

View File

@@ -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<Int?>(null)
private val _remainingTimeSeconds = MutableStateFlow<Int?>(null)
private var timerJob: kotlinx.coroutines.Job? = null
val uiState: StateFlow<QuizUiState> = combine(
suspend { getQuizUseCase() }.asFlow(),
private val quiz: StateFlow<QuizUiState> = flow {
emit(QuizUiState.Success(getQuizUseCase()))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = QuizUiState.Loading,
)
private val _selectedChoiceIndex = MutableStateFlow<Int?>(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<ScreenUiState> = 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,
)