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.
This commit is contained in:
2025-09-04 13:56:33 +02:00
parent 710dedb0cc
commit f0bd963d2d
3 changed files with 108 additions and 10 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,5 +1,8 @@
package dev.adriankuta.kahootquiz.ui.quiz
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -26,6 +29,7 @@ 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.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
@@ -43,6 +47,7 @@ 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.Purple
import dev.adriankuta.kahootquiz.core.designsystem.Red
import dev.adriankuta.kahootquiz.core.designsystem.Red2
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
@@ -90,6 +95,8 @@ private fun QuizScreen(
.fillMaxWidth()
.height(72.dp)
.padding(8.dp),
currentQuestionIndex = uiState.currentQuestionIndex,
totalQuestions = uiState.totalQuestions,
)
QuestionContent(
question = uiState.currentQuestion ?: return,
@@ -101,6 +108,12 @@ private fun QuizScreen(
answer = uiState.answer,
onSelect = onSelect,
)
// Timer below choices
TimerBar(
totalSeconds = uiState.totalTimeSeconds,
remainingSeconds = uiState.remainingTimeSeconds,
modifier = Modifier.padding(8.dp),
)
}
}
}
@@ -108,17 +121,19 @@ private fun QuizScreen(
@Composable
private fun Toolbar(
modifier: Modifier = Modifier,
currentQuestionIndex: Int = 0,
totalQuestions: Int = 0,
) {
Box(
modifier = modifier,
) {
Text(
text = "2/24",
text = "${currentQuestionIndex + 1}/$totalQuestions",
modifier = Modifier
.align(Alignment.CenterStart)
.background(
color = Grey,
shape = RoundedCornerShape(60.dp),
shape = RoundedCornerShape(percent = 50),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
@@ -128,7 +143,7 @@ private fun Toolbar(
.align(Alignment.Center)
.background(
color = Grey,
shape = RoundedCornerShape(60.dp),
shape = RoundedCornerShape(percent = 50),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
@@ -319,6 +334,37 @@ private fun ChoiceItemRevealed(
}
}
@Composable
private fun TimerBar(
totalSeconds: Int,
remainingSeconds: Int,
modifier: Modifier = Modifier,
) {
val progress: Float by animateFloatAsState(
targetValue = (remainingSeconds.toFloat()) / totalSeconds,
label = "Timer",
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
Box(
modifier = modifier
.fillMaxWidth(progress)
.background(
color = Purple,
shape = RoundedCornerShape(percent = 50),
),
) {
Text(
text = "$remainingSeconds",
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp)
.clipToBounds(),
color = Color.White,
)
}
}
@Preview
@Composable
@@ -342,6 +388,9 @@ private fun QuizScreenPreview() {
QuizScreen(
uiState = QuizUiState(
currentQuestion = sampleQuestion,
totalQuestions = 12,
totalTimeSeconds = 30,
remainingTimeSeconds = 10,
),
onSelect = {},
)
@@ -373,6 +422,9 @@ private fun QuizScreenRevealedAnswerPreview() {
answer = AnswerUiState(
selectedChoiceIndex = 1,
),
totalQuestions = 12,
totalTimeSeconds = 30,
remainingTimeSeconds = 10,
),
onSelect = {},
)

View File

@@ -11,20 +11,33 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
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(),
_selectedChoiceIndex,
) { quiz, selectedChoiceIndex ->
_remainingTimeSeconds,
) { quiz, selectedChoiceIndex, remaining ->
val currentQuestion = quiz.questions.first()
val totalSeconds = (currentQuestion.time ?: 30.seconds).inWholeSeconds.toInt()
QuizUiState(
currentQuestion = quiz.questions.first(),
currentQuestion = currentQuestion,
answer = selectedChoiceIndex?.let { AnswerUiState(it) },
currentQuestionIndex = 0,
totalQuestions = quiz.questions.size,
totalTimeSeconds = totalSeconds,
remainingTimeSeconds = remaining ?: totalSeconds,
)
}.stateIn(
scope = viewModelScope,
@@ -33,13 +46,45 @@ class QuizScreenViewModel @Inject constructor(
)
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)
}
}
private fun startCountdown(totalSeconds: Int?) {
timerJob?.cancel()
if (totalSeconds == null || totalSeconds <= 0) 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
}
}
}
}
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,
)
data class AnswerUiState(