Refactor: Extract QuizScreen composables and improve timer logic

This commit refactors `QuizScreen.kt` by extracting several composable functions for better organization and readability. It also includes improvements to the timer logic and minor UI adjustments.

Key changes:

- **QuizScreen.kt:**
    - Extracted `QuizScreenLoading` and `QuizScreenSuccess` composables to handle different UI states.
    - Further decomposed `QuizScreenSuccess` into `LazyListScope` extension functions for `toolbar`, `questionContent`, `choices`, `timer`, and `continueButton`. This improves the structure of the `LazyColumn` and allows for more granular recomposition.
    - Improved `TimerBar` logic:
        - Ensured `targetValue` for `animateFloatAsState` is properly calculated to avoid division by zero when `totalSeconds` is 0.
        - Coerced the progress `Float` value to be within `0f` and `1f` before applying it to `fillMaxWidth`.
    - Changed `contentDescription` of the type icon in `QuizType` to `null` as it's decorative.
    - Used `androidx.compose.runtime.remember` for `questionText` in `QuestionContent` to avoid unnecessary recomposition of `HtmlCompat.fromHtml`.
This commit is contained in:
2025-09-04 18:29:45 +02:00
parent 7d38facda5
commit 7cd3394098

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -92,13 +93,52 @@ private fun QuizScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
when (uiState) { when (uiState) {
ScreenUiState.Loading -> CircularProgressIndicator() ScreenUiState.Loading -> QuizScreenLoading()
is ScreenUiState.Success -> LazyColumn( is ScreenUiState.Success -> QuizScreenSuccess(
modifier = Modifier uiState = uiState,
onSelect = onSelect,
onContinue = onContinue,
)
}
}
}
@Composable
private fun QuizScreenLoading(
modifier: Modifier = Modifier,
) {
CircularProgressIndicator()
}
@Composable
private fun QuizScreenSuccess(
uiState: ScreenUiState.Success,
onSelect: (Int) -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.animateContentSize(), .animateContentSize(),
) { ) {
item { 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( Toolbar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -108,37 +148,39 @@ private fun QuizScreen(
totalQuestions = uiState.totalQuestions, totalQuestions = uiState.totalQuestions,
) )
} }
item { }
private fun LazyListScope.questionContent(
uiState: ScreenUiState.Success,
) {
if (uiState.currentQuestion != null) {
item(key = "question_${uiState.currentQuestionIndex}") {
QuestionContent( QuestionContent(
question = uiState.currentQuestion ?: return@item, question = uiState.currentQuestion,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.animateItem(), .animateItem(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
item {
Choices(
choices = uiState.currentQuestion?.choices
?: emptyList(), // TODO remove empty list
selectedChoiceIndex = uiState.selectedChoiceIndex,
onSelect = onSelect,
modifier = Modifier.padding(8.dp),
)
} }
}
// Timer below choices private fun LazyListScope.timer(uiState: ScreenUiState.Success) {
if (uiState.selectedChoiceIndex == null && uiState.timerState.totalTimeSeconds > 0) { item(key = "timer_${uiState.currentQuestionIndex}") {
item {
TimerBar( TimerBar(
totalSeconds = uiState.timerState.totalTimeSeconds, totalSeconds = uiState.timerState.totalTimeSeconds,
remainingSeconds = uiState.timerState.remainingTimeSeconds, remainingSeconds = uiState.timerState.remainingTimeSeconds,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
) )
} }
} else { }
item {
private fun LazyListScope.continueButton(
uiState: ScreenUiState.Success,
onContinue: () -> Unit,
) {
item(key = "continue_${uiState.currentQuestionIndex}") {
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -157,9 +199,22 @@ private fun QuizScreen(
} }
} }
} }
} }
}
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),
)
}
} }
} }
} }
@@ -195,7 +250,7 @@ private fun Toolbar(
) { ) {
Image( Image(
painter = painterResource(id = DesignR.drawable.ic_type), painter = painterResource(id = DesignR.drawable.ic_type),
contentDescription = "", contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
@@ -224,11 +279,14 @@ private fun QuestionContent(
.clip(shape = RoundedCornerShape(4.dp)), .clip(shape = RoundedCornerShape(4.dp)),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( val questionText = androidx.compose.runtime.remember(question.question) {
text = HtmlCompat.fromHtml( HtmlCompat.fromHtml(
question.question ?: "", question.question ?: "",
HtmlCompat.FROM_HTML_MODE_COMPACT, HtmlCompat.FROM_HTML_MODE_COMPACT,
).toAnnotatedString(), ).toAnnotatedString()
}
Text(
text = questionText,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -393,15 +451,17 @@ private fun TimerBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val target =
if (totalSeconds <= 0) 0f else (remainingSeconds.toFloat() / totalSeconds).coerceIn(0f, 1f)
val progress: Float by animateFloatAsState( val progress: Float by animateFloatAsState(
targetValue = (remainingSeconds.toFloat()) / totalSeconds, targetValue = target,
label = "Timer", label = "Timer",
animationSpec = tween(durationMillis = 1000, easing = LinearEasing), animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
) )
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth(progress) .fillMaxWidth(progress.coerceIn(0f, 1f))
.background( .background(
color = Purple, color = Purple,
shape = RoundedCornerShape(percent = 50), shape = RoundedCornerShape(percent = 50),