feat: Refactor ViewModel state production and add lint baselines

This commit refactors how `ScreenUiState` is produced in `QuizScreenViewModel` by extracting the logic into a private `screenUiState` function that combines various flows. It also introduces `Result` sealed interface and `asResult()` Flow extension for handling asynchronous operations. Additionally, lint baseline files have been added to all modules and a Detekt configuration file for the `ui:quiz` module.

Key changes:

- **ViewModel Refactoring (`ui:quiz` module):**
    - Introduced `Result.kt` with a sealed interface for `Success`, `Error`, and `Loading` states, along with an `asResult()` extension function for `Flow` to wrap emissions in `Result`.
    - In `QuizScreenViewModel.kt`:
        - The `quiz` state flow now uses `asResult()` and maps the `Result` to `QuizUiState`, handling potential error states by defaulting to `QuizUiState.Loading` (actual error UI handling is noted as a TODO).
        - The logic for combining flows to produce `ScreenUiState` has been extracted into a new private function `screenUiState()`. This function takes the necessary flows as parameters and returns a `Flow<ScreenUiState>`.
        - The `uiState` flow now uses this new `screenUiState()` function for its production.
- **Lint and Detekt Configuration:**
    - Added `lint-baseline.xml` files to the following modules to establish a baseline for lint warnings:
        - `app`
        - `core/designsystem`
        - `core/network`
        - `domain`
        - `model/data`
        - `ui/quiz`
    - Added `config/detekt/detekt.yml` to the `ui:quiz` module to configure Detekt rules, including exceptions for Jetpack Compose naming conventions, complexity rules for Composable functions, and style guidelines for magic numbers and unused private members. It also enables trailing commas for call sites and declaration sites.
- **Binary File:**
    - Added `App.apk`.
This commit is contained in:
2025-09-04 22:41:06 +02:00
parent 77a3dd9eeb
commit 59218cc2e1
10 changed files with 451 additions and 28 deletions

View File

@@ -0,0 +1,33 @@
# Exceptions for compose. See https://detekt.dev/docs/introduction/compose
naming:
FunctionNaming:
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
TopLevelPropertyNaming:
constantPattern: '[A-Z][A-Za-z0-9]*'
complexity:
LongParameterList:
ignoreAnnotated: ['Composable']
TooManyFunctions:
ignoreAnnotatedFunctions: ['Preview']
style:
MagicNumber:
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotated: ['Composable']
UnusedPrivateMember:
ignoreAnnotated: ['Composable']
# Deviations from defaults
formatting:
TrailingCommaOnCallSite:
active: true
autoCorrect: true
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: true
autoCorrect: true
useTrailingCommaOnDeclarationSite: true

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.13.0-rc02" type="baseline" client="gradle" dependencies="false" name="AGP (8.13.0-rc02)" variant="all" version="8.13.0-rc02">
</issues>

View File

@@ -6,13 +6,17 @@ 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 dev.adriankuta.kahootquiz.ui.quiz.utils.Result
import dev.adriankuta.kahootquiz.ui.quiz.utils.asResult
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -26,6 +30,14 @@ class QuizScreenViewModel @Inject constructor(
private val quiz: StateFlow<QuizUiState> = flow {
emit(QuizUiState.Success(getQuizUseCase()))
}
.asResult()
.map { quizResult ->
when (quizResult) {
is Result.Error -> QuizUiState.Loading // Todo error handling not implemented on UI
Result.Loading -> QuizUiState.Loading
is Result.Success -> quizResult.data
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@@ -52,34 +64,12 @@ class QuizScreenViewModel @Inject constructor(
}
}
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)
val isAnswerCorrect = selectedChoiceIndex?.let { idx ->
currentQuestion?.choices?.getOrNull(idx)?.correct == true
}
ScreenUiState.Success(
currentQuestion = currentQuestion,
selectedChoiceIndex = selectedChoiceIndex,
currentQuestionIndex = currentQuestionIndex,
totalQuestions = quizState.quiz.questions.size,
timerState = TimerState(
remainingTimeSeconds = remainingTimeSeconds,
totalTimeSeconds = currentQuestion?.time?.inWholeSeconds?.toInt() ?: 0,
),
isAnswerCorrect = isAnswerCorrect,
)
}
}
}
val uiState: StateFlow<ScreenUiState> = screenUiState(
quizFlow = quiz,
selectedChoiceIndexFlow = _selectedChoiceIndex,
remainingTimeSecondsFlow = _remainingTimeSeconds,
currentQuestionIndexFlow = _currentQuestionIndex,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@@ -137,6 +127,40 @@ class QuizScreenViewModel @Inject constructor(
}
}
private fun screenUiState(
quizFlow: StateFlow<QuizUiState>,
selectedChoiceIndexFlow: Flow<Int?>,
remainingTimeSecondsFlow: Flow<Int>,
currentQuestionIndexFlow: Flow<Int>,
): Flow<ScreenUiState> = combine(
quizFlow,
selectedChoiceIndexFlow,
remainingTimeSecondsFlow,
currentQuestionIndexFlow,
) { quizState, selectedChoiceIndex, remainingTimeSeconds, currentQuestionIndex ->
when (quizState) {
QuizUiState.Loading -> ScreenUiState.Loading
is QuizUiState.Success -> {
val currentQuestion = quizState.quiz.questions.getOrNull(currentQuestionIndex)
val isAnswerCorrect = selectedChoiceIndex?.let { idx ->
currentQuestion?.choices?.getOrNull(idx)?.correct == true
}
ScreenUiState.Success(
currentQuestion = currentQuestion,
selectedChoiceIndex = selectedChoiceIndex,
currentQuestionIndex = currentQuestionIndex,
totalQuestions = quizState.quiz.questions.size,
timerState = TimerState(
remainingTimeSeconds = remainingTimeSeconds,
totalTimeSeconds = currentQuestion?.time?.inWholeSeconds?.toInt() ?: 0,
),
isAnswerCorrect = isAnswerCorrect,
)
}
}
}
sealed interface QuizUiState {
data object Loading : QuizUiState
data class Success(

View File

@@ -0,0 +1,16 @@
package dev.adriankuta.kahootquiz.ui.quiz.utils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }