mirror of
https://github.com/AdrianKuta/KahootQuiz.git
synced 2025-09-14 17:24:21 +02:00
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:
@@ -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 = {},
|
||||
)
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user