mirror of
				https://github.com/AdrianKuta/KahootQuiz.git
				synced 2025-10-30 16:33:42 +01:00 
			
		
		
		
	Compare commits
	
		
			7 Commits
		
	
	
		
			710dedb0cc
			...
			12638f33d8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 12638f33d8 | ||
|   | 3194d2a813 | ||
|   | 7cd3394098 | ||
|   | 7d38facda5 | ||
|   | d2fce7e7b9 | ||
|   | 41fd729271 | ||
|   | f0bd963d2d | 
| @@ -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) | ||||
|   | ||||
| @@ -1,26 +1,19 @@ | ||||
| package dev.adriankuta.kahootquiz.ui.quiz | ||||
|  | ||||
| import androidx.compose.animation.animateContentSize | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.background | ||||
| 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.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| 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.lazy.LazyListScope | ||||
| 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 | ||||
| @@ -30,26 +23,18 @@ import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.layout.ContentScale | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.text.HtmlCompat | ||||
| import androidx.hilt.navigation.compose.hiltViewModel | ||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||
| import coil3.compose.AsyncImage | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Blue2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Green | ||||
| 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.Red | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Red2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Yellow3 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString | ||||
| import dev.adriankuta.kahootquiz.domain.models.Choice | ||||
| import dev.adriankuta.kahootquiz.domain.models.Question | ||||
| import dev.adriankuta.kahootquiz.ui.quiz.components.Choices | ||||
| import dev.adriankuta.kahootquiz.ui.quiz.components.QuestionContent | ||||
| import dev.adriankuta.kahootquiz.ui.quiz.components.TimerBar | ||||
| import dev.adriankuta.kahootquiz.ui.quiz.components.Toolbar | ||||
| import kotlin.time.Duration.Companion.seconds | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR | ||||
|  | ||||
| @@ -64,14 +49,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()) { | ||||
| @@ -81,244 +68,134 @@ private fun QuizScreen( | ||||
|             contentScale = ContentScale.Crop, | ||||
|             modifier = Modifier.fillMaxSize(), | ||||
|         ) | ||||
|         Column( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth(), | ||||
|         ) { | ||||
|             Toolbar( | ||||
|                 modifier = Modifier | ||||
|                     .fillMaxWidth() | ||||
|                     .height(72.dp) | ||||
|                     .padding(8.dp), | ||||
|         when (uiState) { | ||||
|             ScreenUiState.Loading -> QuizScreenLoading() | ||||
|             is ScreenUiState.Success -> QuizScreenSuccess( | ||||
|                 uiState = uiState, | ||||
|                 onSelect = onSelect, | ||||
|                 onContinue = onContinue, | ||||
|             ) | ||||
|  | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun QuizScreenLoading( | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     CircularProgressIndicator( | ||||
|         modifier = modifier, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun QuizScreenSuccess( | ||||
|     uiState: ScreenUiState.Success, | ||||
|     onSelect: (Int) -> Unit, | ||||
|     onContinue: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     LazyColumn( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .animateContentSize(), | ||||
|     ) { | ||||
|         toolbar(uiState) | ||||
|         questionContent(uiState) | ||||
|         choices(uiState, onSelect) | ||||
|         // Timer below choices | ||||
|         if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) { | ||||
|             timer(uiState) | ||||
|         } else { | ||||
|             continueButton(uiState, onContinue) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun LazyListScope.toolbar( | ||||
|     uiState: ScreenUiState.Success, | ||||
| ) { | ||||
|     item(key = "toolbar") { | ||||
|         Toolbar( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .height(72.dp) | ||||
|                 .padding(8.dp), | ||||
|             currentQuestionIndex = uiState.currentQuestionIndex, | ||||
|             totalQuestions = uiState.totalQuestions, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun LazyListScope.questionContent( | ||||
|     uiState: ScreenUiState.Success, | ||||
| ) { | ||||
|     if (uiState.currentQuestion != null) { | ||||
|         item(key = "question_${uiState.currentQuestionIndex}") { | ||||
|             QuestionContent( | ||||
|                 question = uiState.currentQuestion ?: return, | ||||
|                 modifier = Modifier.padding(horizontal = 8.dp), | ||||
|                 question = uiState.currentQuestion, | ||||
|                 modifier = Modifier | ||||
|                     .padding(horizontal = 8.dp) | ||||
|                     .animateItem(), | ||||
|             ) | ||||
|             Spacer(Modifier.height(8.dp)) | ||||
|             Choices( | ||||
|                 choices = uiState.currentQuestion.choices ?: emptyList(), // TODO remove empty list | ||||
|                 answer = uiState.answer, | ||||
|                 onSelect = onSelect, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun Toolbar( | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Box( | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         Text( | ||||
|             text = "2/24", | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.CenterStart) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(60.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
| private fun LazyListScope.timer(uiState: ScreenUiState.Success) { | ||||
|     item(key = "timer_${uiState.currentQuestionIndex}") { | ||||
|         TimerBar( | ||||
|             totalSeconds = uiState.timerState.totalTimeSeconds, | ||||
|             remainingSeconds = uiState.timerState.remainingTimeSeconds, | ||||
|             modifier = Modifier.padding(8.dp), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
|         Row( | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.Center) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(60.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
| private fun LazyListScope.continueButton( | ||||
|     uiState: ScreenUiState.Success, | ||||
|     onContinue: () -> Unit, | ||||
| ) { | ||||
|     item(key = "continue_${uiState.currentQuestionIndex}") { | ||||
|         Box( | ||||
|             modifier = Modifier.fillMaxWidth(), | ||||
|         ) { | ||||
|             Image( | ||||
|                 painter = painterResource(id = DesignR.drawable.ic_type), | ||||
|                 contentDescription = "", | ||||
|                 modifier = Modifier.size(24.dp), | ||||
|             ) | ||||
|             Spacer(Modifier.width(4.dp)) | ||||
|             Text( | ||||
|                 text = stringResource(R.string.quiz), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun QuestionContent( | ||||
|     question: Question, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Column( | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         AsyncImage( | ||||
|             model = question.image, | ||||
|             contentDescription = question.imageMetadata?.altText, | ||||
|             contentScale = ContentScale.FillWidth, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .heightIn(min = 200.dp), | ||||
|         ) | ||||
|         Spacer(Modifier.height(16.dp)) | ||||
|         Text( | ||||
|             text = HtmlCompat.fromHtml( | ||||
|                 question.question ?: "", | ||||
|                 HtmlCompat.FROM_HTML_MODE_COMPACT, | ||||
|             ).toAnnotatedString(), | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .background( | ||||
|                     color = Color.White, | ||||
|                     shape = RoundedCornerShape(4.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 16.dp), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun Choices( | ||||
|     choices: List<Choice>, | ||||
|     onSelect: (Int) -> Unit, | ||||
|     answer: AnswerUiState?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     LazyVerticalGrid( | ||||
|         columns = GridCells.Fixed(2), | ||||
|         contentPadding = PaddingValues(8.dp), | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|         verticalArrangement = Arrangement.spacedBy(8.dp), | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         itemsIndexed(choices) { index, choice -> | ||||
|             ChoiceItem( | ||||
|                 choice = choice, | ||||
|                 index = index, | ||||
|                 answer = answer, | ||||
|                 onClick = { onSelect(index) }, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItem( | ||||
|     choice: Choice, | ||||
|     onClick: () -> Unit, | ||||
|     index: Int, | ||||
|     answer: AnswerUiState?, | ||||
| ) { | ||||
|     if (answer != null) { | ||||
|         ChoiceItemRevealed( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             isSelected = answer.selectedChoiceIndex == index, | ||||
|         ) | ||||
|     } else { | ||||
|         ChoiceItemDefault( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             onClick = onClick, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemDefault( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     onClick: () -> Unit, | ||||
| ) { | ||||
|     val backgroundColor = when (index) { | ||||
|         0 -> Red2 | ||||
|         1 -> Blue2 | ||||
|         2 -> Yellow3 | ||||
|         3 -> Green2 | ||||
|         else -> Color.Gray | ||||
|     } | ||||
|  | ||||
|     // TODO Add icons | ||||
|     val icon = when (index) { | ||||
|         0 -> DesignR.drawable.ic_triangle | ||||
|         1 -> DesignR.drawable.ic_diamond | ||||
|         2 -> DesignR.drawable.ic_circle | ||||
|         else -> DesignR.drawable.ic_square | ||||
|     } | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp) | ||||
|             .clickable( | ||||
|                 onClick = onClick, | ||||
|             ), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(id = icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .padding(8.dp) | ||||
|                 .size(32.dp), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemRevealed( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     isSelected: Boolean, | ||||
| ) { | ||||
|     val backgroundColor = when { | ||||
|         isSelected && !choice.correct -> Red | ||||
|         choice.correct -> Green | ||||
|         else -> Pink | ||||
|     } | ||||
|  | ||||
|     val icon = if (choice.correct) { | ||||
|         DesignR.drawable.ic_correct | ||||
|     } else { | ||||
|         DesignR.drawable.ic_wrong | ||||
|     } | ||||
|  | ||||
|     val alignment = if (index % 2 == 0) { | ||||
|         Alignment.TopStart | ||||
|     } else { | ||||
|         Alignment.TopEnd | ||||
|     } | ||||
|  | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .align(alignment) | ||||
|                 .offset( | ||||
|                     x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp, | ||||
|                     (-8).dp, | ||||
|             FilledTonalButton( | ||||
|                 onClick = onContinue, | ||||
|                 colors = ButtonDefaults.filledTonalButtonColors().copy( | ||||
|                     containerColor = Grey, | ||||
|                     contentColor = Color.Black, | ||||
|                 ), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|                 shape = RoundedCornerShape(4.dp), | ||||
|                 modifier = Modifier.align(Alignment.Center), | ||||
|             ) { | ||||
|                 Text( | ||||
|                     text = stringResource(R.string.continue_text), | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun LazyListScope.choices( | ||||
|     uiState: ScreenUiState.Success, | ||||
|     onSelect: (Int) -> Unit, | ||||
| ) { | ||||
|     uiState.currentQuestion?.choices?.let { choicesList -> | ||||
|         if (choicesList.isNotEmpty()) { | ||||
|             item(key = "choices_${uiState.currentQuestionIndex}") { | ||||
|                 Choices( | ||||
|                     choices = choicesList, | ||||
|                     selectedChoiceIndex = uiState.selectedChoiceIndex, | ||||
|                     onSelect = onSelect, | ||||
|                     modifier = Modifier.padding(8.dp), | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Preview | ||||
| @Composable | ||||
| @@ -340,10 +217,13 @@ private fun QuizScreenPreview() { | ||||
|             imageMetadata = null, | ||||
|         ) | ||||
|         QuizScreen( | ||||
|             uiState = QuizUiState( | ||||
|             uiState = ScreenUiState.Success( | ||||
|                 currentQuestion = sampleQuestion, | ||||
|                 selectedChoiceIndex = null, | ||||
|                 totalQuestions = 12, | ||||
|             ), | ||||
|             onSelect = {}, | ||||
|             onContinue = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -368,13 +248,13 @@ private fun QuizScreenRevealedAnswerPreview() { | ||||
|             imageMetadata = null, | ||||
|         ) | ||||
|         QuizScreen( | ||||
|             uiState = QuizUiState( | ||||
|             uiState = ScreenUiState.Success( | ||||
|                 currentQuestion = sampleQuestion, | ||||
|                 answer = AnswerUiState( | ||||
|                     selectedChoiceIndex = 1, | ||||
|                 ), | ||||
|                 selectedChoiceIndex = 1, | ||||
|                 totalQuestions = 12, | ||||
|             ), | ||||
|             onSelect = {}, | ||||
|             onContinue = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,44 +4,154 @@ 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.flow | ||||
| import kotlinx.coroutines.flow.stateIn | ||||
| import kotlinx.coroutines.launch | ||||
| import javax.inject.Inject | ||||
|  | ||||
| @HiltViewModel | ||||
| class QuizScreenViewModel @Inject constructor( | ||||
|     private val getQuizUseCase: GetQuizUseCase, | ||||
| ) : ViewModel() { | ||||
|     private val _selectedChoiceIndex = MutableStateFlow<Int?>(null) | ||||
|     val uiState: StateFlow<QuizUiState> = combine( | ||||
|         suspend { getQuizUseCase() }.asFlow(), | ||||
|         _selectedChoiceIndex, | ||||
|     ) { quiz, selectedChoiceIndex -> | ||||
|         QuizUiState( | ||||
|             currentQuestion = quiz.questions.first(), | ||||
|             answer = selectedChoiceIndex?.let { AnswerUiState(it) }, | ||||
|  | ||||
|     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(0) | ||||
|     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 (timerJob == null && _currentQuestionIndex.value == 0) { | ||||
|                         val firstQuestionTime = | ||||
|                             quizState.quiz.questions.getOrNull(0)?.time?.inWholeSeconds?.toInt() | ||||
|                         startCountdown(firstQuestionTime) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     val uiState: StateFlow<ScreenUiState> = combine( | ||||
|         quiz, | ||||
|         _selectedChoiceIndex, | ||||
|         _remainingTimeSeconds, | ||||
|         _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() | ||||
|         timerJob = null | ||||
|         _selectedChoiceIndex.value = index | ||||
|     } | ||||
|  | ||||
|     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() | ||||
|                 timerJob = null | ||||
|                 _remainingTimeSeconds.value = 0 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun startCountdown(totalSeconds: Int?) { | ||||
|         timerJob?.cancel() | ||||
|         if (totalSeconds == null || totalSeconds <= 0) { | ||||
|             _remainingTimeSeconds.value = 0 | ||||
|             timerJob = null | ||||
|             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 | ||||
|             } | ||||
|             timerJob = null | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| data class QuizUiState( | ||||
|     val currentQuestion: Question? = null, | ||||
|     val answer: AnswerUiState? = null, | ||||
| ) | ||||
| 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, | ||||
| ) | ||||
|   | ||||
| @@ -0,0 +1,175 @@ | ||||
| package dev.adriankuta.kahootquiz.ui.quiz.components | ||||
|  | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.FlowRow | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.offset | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.size | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.unit.dp | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Blue2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Green | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Green2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Pink | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Red | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Red2 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Yellow3 | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.contrastiveTo | ||||
| import dev.adriankuta.kahootquiz.domain.models.Choice | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR | ||||
|  | ||||
| @Composable | ||||
| fun Choices( | ||||
|     choices: List<Choice>, | ||||
|     onSelect: (Int) -> Unit, | ||||
|     selectedChoiceIndex: Int?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     FlowRow( | ||||
|         maxItemsInEachRow = 2, | ||||
|         modifier = modifier, | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|         verticalArrangement = Arrangement.spacedBy(8.dp), | ||||
|     ) { | ||||
|         choices.forEachIndexed { index, choice -> | ||||
|             ChoiceItem( | ||||
|                 choice = choice, | ||||
|                 index = index, | ||||
|                 selectedChoiceIndex = selectedChoiceIndex, | ||||
|                 onClick = { onSelect(index) }, | ||||
|                 modifier = Modifier.weight(1f), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItem( | ||||
|     choice: Choice, | ||||
|     onClick: () -> Unit, | ||||
|     index: Int, | ||||
|     selectedChoiceIndex: Int?, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     if (selectedChoiceIndex != null) { | ||||
|         ChoiceItemRevealed( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             isSelected = selectedChoiceIndex == index, | ||||
|             modifier = modifier, | ||||
|         ) | ||||
|     } else { | ||||
|         ChoiceItemDefault( | ||||
|             choice = choice, | ||||
|             index = index, | ||||
|             onClick = onClick, | ||||
|             modifier = modifier, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemDefault( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     onClick: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     val backgroundColor = when (index) { | ||||
|         0 -> Red2 | ||||
|         1 -> Blue2 | ||||
|         2 -> Yellow3 | ||||
|         3 -> Green2 | ||||
|         else -> Color.Gray | ||||
|     } | ||||
|  | ||||
|     val icon = when (index) { | ||||
|         0 -> DesignR.drawable.ic_triangle | ||||
|         1 -> DesignR.drawable.ic_diamond | ||||
|         2 -> DesignR.drawable.ic_circle | ||||
|         else -> DesignR.drawable.ic_square | ||||
|     } | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp) | ||||
|             .clickable( | ||||
|                 onClick = onClick, | ||||
|             ), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(id = icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .padding(8.dp) | ||||
|                 .size(32.dp), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ChoiceItemRevealed( | ||||
|     choice: Choice, | ||||
|     index: Int, | ||||
|     isSelected: Boolean, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     val backgroundColor = when { | ||||
|         isSelected && !choice.correct -> Red | ||||
|         choice.correct -> Green | ||||
|         else -> Pink | ||||
|     } | ||||
|  | ||||
|     val icon = if (choice.correct) { | ||||
|         DesignR.drawable.ic_correct | ||||
|     } else { | ||||
|         DesignR.drawable.ic_wrong | ||||
|     } | ||||
|  | ||||
|     val alignment = if (index % 2 == 0) { | ||||
|         Alignment.TopStart | ||||
|     } else { | ||||
|         Alignment.TopEnd | ||||
|     } | ||||
|  | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|             .background(backgroundColor, shape = RoundedCornerShape(4.dp)) | ||||
|             .height(100.dp), | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(icon), | ||||
|             contentDescription = null, | ||||
|             modifier = Modifier | ||||
|                 .align(alignment) | ||||
|                 .offset( | ||||
|                     x = if (alignment == Alignment.TopStart) (-8).dp else (8).dp, | ||||
|                     y = (-8).dp, | ||||
|                 ), | ||||
|         ) | ||||
|         Text( | ||||
|             text = choice.answer ?: "", | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier.align(Alignment.Center), | ||||
|             color = contrastiveTo(backgroundColor), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| package dev.adriankuta.kahootquiz.ui.quiz.components | ||||
|  | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.layout.ContentScale | ||||
| import androidx.compose.ui.text.style.TextAlign | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.text.HtmlCompat | ||||
| import coil3.compose.AsyncImage | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.toAnnotatedString | ||||
| import dev.adriankuta.kahootquiz.domain.models.Question | ||||
|  | ||||
| @Composable | ||||
| fun QuestionContent( | ||||
|     question: Question, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Column( | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         AsyncImage( | ||||
|             model = question.image, | ||||
|             contentDescription = question.imageMetadata?.altText, | ||||
|             contentScale = ContentScale.FillWidth, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .heightIn(min = 200.dp) | ||||
|                 .clip(shape = RoundedCornerShape(4.dp)), | ||||
|         ) | ||||
|         Spacer(Modifier.height(16.dp)) | ||||
|         val questionText = androidx.compose.runtime.remember(question.question) { | ||||
|             HtmlCompat.fromHtml( | ||||
|                 question.question ?: "", | ||||
|                 HtmlCompat.FROM_HTML_MODE_COMPACT, | ||||
|             ).toAnnotatedString() | ||||
|         } | ||||
|         Text( | ||||
|             text = questionText, | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .background( | ||||
|                     color = Color.White, | ||||
|                     shape = RoundedCornerShape(4.dp), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 16.dp), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,50 @@ | ||||
| package dev.adriankuta.kahootquiz.ui.quiz.components | ||||
|  | ||||
| import androidx.compose.animation.core.LinearEasing | ||||
| import androidx.compose.animation.core.animateFloatAsState | ||||
| import androidx.compose.animation.core.tween | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| 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.graphics.Color | ||||
| import androidx.compose.ui.unit.dp | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Purple | ||||
|  | ||||
| @Composable | ||||
| fun TimerBar( | ||||
|     totalSeconds: Int, | ||||
|     remainingSeconds: Int, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     val target = | ||||
|         if (totalSeconds <= 0) 0f else (remainingSeconds.toFloat() / totalSeconds).coerceIn(0f, 1f) | ||||
|     val progress: Float by animateFloatAsState( | ||||
|         targetValue = target, | ||||
|         label = "Timer", | ||||
|         animationSpec = tween(durationMillis = 1000, easing = LinearEasing), | ||||
|     ) | ||||
|  | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth(progress.coerceIn(0f, 1f)) | ||||
|             .background( | ||||
|                 color = Purple, | ||||
|                 shape = RoundedCornerShape(percent = 50), | ||||
|             ), | ||||
|     ) { | ||||
|         Text( | ||||
|             text = "$remainingSeconds", | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.CenterEnd) | ||||
|                 .padding(end = 8.dp), | ||||
|             color = Color.White, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,63 @@ | ||||
| package dev.adriankuta.kahootquiz.ui.quiz.components | ||||
|  | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.size | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.Grey | ||||
| import dev.adriankuta.kahootquiz.ui.quiz.R | ||||
| import dev.adriankuta.kahootquiz.core.designsystem.R as DesignR | ||||
|  | ||||
| @Composable | ||||
| fun Toolbar( | ||||
|     modifier: Modifier = Modifier, | ||||
|     currentQuestionIndex: Int = 0, | ||||
|     totalQuestions: Int = 0, | ||||
| ) { | ||||
|     Box( | ||||
|         modifier = modifier, | ||||
|     ) { | ||||
|         Text( | ||||
|             text = "${currentQuestionIndex + 1}/$totalQuestions", | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.CenterStart) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(percent = 50), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
|         ) | ||||
|  | ||||
|         Row( | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.Center) | ||||
|                 .background( | ||||
|                     color = Grey, | ||||
|                     shape = RoundedCornerShape(percent = 50), | ||||
|                 ) | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp), | ||||
|         ) { | ||||
|             Image( | ||||
|                 painter = painterResource(id = DesignR.drawable.ic_type), | ||||
|                 contentDescription = null, | ||||
|                 modifier = Modifier.size(24.dp), | ||||
|             ) | ||||
|             Spacer(Modifier.width(4.dp)) | ||||
|             Text( | ||||
|                 text = stringResource(R.string.quiz), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <string name="quiz">Quiz</string> | ||||
|     <string name="continue_text">Continue</string> | ||||
| </resources> | ||||
		Reference in New Issue
	
	Block a user