mirror of
				https://github.com/AdrianKuta/KahootQuiz.git
				synced 2025-10-31 00:43:40 +01: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