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:
@@ -19,11 +19,12 @@ val Purple40 = Color(0xFF6650a4)
|
|||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
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 Blue2 = Color(0xFF1368CE)
|
||||||
val Yellow3 = Color(0xFFD89E00)
|
|
||||||
val Green = Color(0xFF66BF39)
|
val Green = Color(0xFF66BF39)
|
||||||
val Green2 = Color(0xFF26890C)
|
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)
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
package dev.adriankuta.kahootquiz.ui.quiz
|
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.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -26,6 +29,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
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.Grey
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
|
import dev.adriankuta.kahootquiz.core.designsystem.KahootQuizTheme
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Pink
|
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.Red
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Red2
|
import dev.adriankuta.kahootquiz.core.designsystem.Red2
|
||||||
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
|
import dev.adriankuta.kahootquiz.core.designsystem.Yellow3
|
||||||
@@ -90,6 +95,8 @@ private fun QuizScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(72.dp)
|
.height(72.dp)
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
|
currentQuestionIndex = uiState.currentQuestionIndex,
|
||||||
|
totalQuestions = uiState.totalQuestions,
|
||||||
)
|
)
|
||||||
QuestionContent(
|
QuestionContent(
|
||||||
question = uiState.currentQuestion ?: return,
|
question = uiState.currentQuestion ?: return,
|
||||||
@@ -101,6 +108,12 @@ private fun QuizScreen(
|
|||||||
answer = uiState.answer,
|
answer = uiState.answer,
|
||||||
onSelect = onSelect,
|
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
|
@Composable
|
||||||
private fun Toolbar(
|
private fun Toolbar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
currentQuestionIndex: Int = 0,
|
||||||
|
totalQuestions: Int = 0,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "2/24",
|
text = "${currentQuestionIndex + 1}/$totalQuestions",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.background(
|
.background(
|
||||||
color = Grey,
|
color = Grey,
|
||||||
shape = RoundedCornerShape(60.dp),
|
shape = RoundedCornerShape(percent = 50),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -128,7 +143,7 @@ private fun Toolbar(
|
|||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.background(
|
.background(
|
||||||
color = Grey,
|
color = Grey,
|
||||||
shape = RoundedCornerShape(60.dp),
|
shape = RoundedCornerShape(percent = 50),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
@@ -342,6 +388,9 @@ private fun QuizScreenPreview() {
|
|||||||
QuizScreen(
|
QuizScreen(
|
||||||
uiState = QuizUiState(
|
uiState = QuizUiState(
|
||||||
currentQuestion = sampleQuestion,
|
currentQuestion = sampleQuestion,
|
||||||
|
totalQuestions = 12,
|
||||||
|
totalTimeSeconds = 30,
|
||||||
|
remainingTimeSeconds = 10,
|
||||||
),
|
),
|
||||||
onSelect = {},
|
onSelect = {},
|
||||||
)
|
)
|
||||||
@@ -373,6 +422,9 @@ private fun QuizScreenRevealedAnswerPreview() {
|
|||||||
answer = AnswerUiState(
|
answer = AnswerUiState(
|
||||||
selectedChoiceIndex = 1,
|
selectedChoiceIndex = 1,
|
||||||
),
|
),
|
||||||
|
totalQuestions = 12,
|
||||||
|
totalTimeSeconds = 30,
|
||||||
|
remainingTimeSeconds = 10,
|
||||||
),
|
),
|
||||||
onSelect = {},
|
onSelect = {},
|
||||||
)
|
)
|
||||||
|
@@ -11,20 +11,33 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asFlow
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class QuizScreenViewModel @Inject constructor(
|
class QuizScreenViewModel @Inject constructor(
|
||||||
private val getQuizUseCase: GetQuizUseCase,
|
private val getQuizUseCase: GetQuizUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
|
private val _selectedChoiceIndex = MutableStateFlow<Int?>(null)
|
||||||
|
private val _remainingTimeSeconds = MutableStateFlow<Int?>(null)
|
||||||
|
private var timerJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
val uiState: StateFlow<QuizUiState> = combine(
|
val uiState: StateFlow<QuizUiState> = combine(
|
||||||
suspend { getQuizUseCase() }.asFlow(),
|
suspend { getQuizUseCase() }.asFlow(),
|
||||||
_selectedChoiceIndex,
|
_selectedChoiceIndex,
|
||||||
) { quiz, selectedChoiceIndex ->
|
_remainingTimeSeconds,
|
||||||
|
) { quiz, selectedChoiceIndex, remaining ->
|
||||||
|
val currentQuestion = quiz.questions.first()
|
||||||
|
val totalSeconds = (currentQuestion.time ?: 30.seconds).inWholeSeconds.toInt()
|
||||||
QuizUiState(
|
QuizUiState(
|
||||||
currentQuestion = quiz.questions.first(),
|
currentQuestion = currentQuestion,
|
||||||
answer = selectedChoiceIndex?.let { AnswerUiState(it) },
|
answer = selectedChoiceIndex?.let { AnswerUiState(it) },
|
||||||
|
currentQuestionIndex = 0,
|
||||||
|
totalQuestions = quiz.questions.size,
|
||||||
|
totalTimeSeconds = totalSeconds,
|
||||||
|
remainingTimeSeconds = remaining ?: totalSeconds,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -33,13 +46,45 @@ class QuizScreenViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun onChoiceSelected(index: Int) {
|
fun onChoiceSelected(index: Int) {
|
||||||
|
timerJob?.cancel()
|
||||||
_selectedChoiceIndex.value = index
|
_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(
|
data class QuizUiState(
|
||||||
val currentQuestion: Question? = null,
|
val currentQuestion: Question? = null,
|
||||||
val answer: AnswerUiState? = null,
|
val answer: AnswerUiState? = null,
|
||||||
|
val currentQuestionIndex: Int = 0,
|
||||||
|
val totalQuestions: Int = 0,
|
||||||
|
val totalTimeSeconds: Int = -1,
|
||||||
|
val remainingTimeSeconds: Int = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AnswerUiState(
|
data class AnswerUiState(
|
||||||
|
Reference in New Issue
Block a user