REDI-98: error-handling demo screen (DataError -> UiText pipeline)

A runnable MVI screen (reached from the list overflow menu) that forces a real
DataError.Network case and routes it through the same pipeline a genuine call
uses: Result.Error -> onFailure -> DataError.toUiText() -> design-system
ErrorState. Three distinct cases (NO_INTERNET, NOT_FOUND, SERVER_ERROR) each
render their mapped message; Retry re-issues the last attempt via an Action; a
successful load clears the error. Wired as intra-feature navigation
(ErrorDemoRoute) and registered in Koin (incl. the UseCase factoryOf).
This commit is contained in:
2026-06-10 15:00:27 +02:00
parent 0542d4dc1d
commit cf63095acc
9 changed files with 338 additions and 6 deletions

View File

@@ -0,0 +1,14 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoAction {
/** Force a load that fails with the given [ErrorScenario]. */
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
/** Force a load that succeeds — clears any current error. */
data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */
data object OnRetry : ErrorDemoAction
data object OnBackClick : ErrorDemoAction
}

View File

@@ -0,0 +1,5 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoEvent {
data object NavigateBack : ErrorDemoEvent
}

View File

@@ -0,0 +1,25 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
/**
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` — exactly what the real
* screens hold — so the renderer resolves and shows it the same way.
*/
data class ErrorDemoState(
val isLoading: Boolean = false,
val loaded: Boolean = false,
val error: UiText? = null,
)
/**
* The failure the user asks the demo to reproduce. A presentation-local choice (not a `DataError`)
* so the renderer stays free of domain error types; the ViewModel maps it to the real
* `DataError.Network` case.
*/
enum class ErrorScenario {
NO_INTERNET,
NOT_FOUND,
SERVER_ERROR,
}

View File

@@ -0,0 +1,87 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.core.domain.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.toUiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* UI-agnostic MVI ViewModel for the **error-handling demo** — a runnable walk-through of the whole
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
* through the *same* steps a genuine network call uses:
*
* ```
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
* ```
*
* The outcome is *simulated* (no real request) only so every case — including NO_INTERNET, which you
* can't reliably trigger on demand — is reachable deterministically. [OnRetry] re-issues the last
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
* success). See android-error-handling.
*/
class ErrorDemoViewModel : ViewModel() {
private val _state = MutableStateFlow(ErrorDemoState())
val state = _state.asStateFlow()
private val _events = Channel<ErrorDemoEvent>()
val events = _events.receiveAsFlow()
// Remembered so OnRetry re-issues exactly what was last attempted.
private var lastAttempt: Attempt = Attempt.Success
fun onAction(action: ErrorDemoAction) {
when (action) {
is ErrorDemoAction.OnForceError -> load(Attempt.Fail(action.scenario))
ErrorDemoAction.OnLoadSuccess -> load(Attempt.Success)
ErrorDemoAction.OnRetry -> load(lastAttempt)
ErrorDemoAction.OnBackClick -> viewModelScope.launch {
_events.send(ErrorDemoEvent.NavigateBack)
}
}
}
private fun load(attempt: Attempt) {
lastAttempt = attempt
_state.update { it.copy(isLoading = true, error = null, loaded = false) }
viewModelScope.launch {
delay(LOAD_DELAY_MS) // pretend a request is in flight, so the loading state is visible
simulate(attempt)
.onSuccess { _state.update { it.copy(isLoading = false, loaded = true, error = null) } }
.onFailure { dataError ->
// The crux of the demo: a DataError becomes user-facing UiText right here.
_state.update { it.copy(isLoading = false, error = dataError.toUiText()) }
}
}
}
private fun simulate(attempt: Attempt): Result<Unit, DataError.Network> = when (attempt) {
Attempt.Success -> Result.Success(Unit)
is Attempt.Fail -> Result.Error(attempt.scenario.toDataError())
}
private sealed interface Attempt {
data object Success : Attempt
data class Fail(val scenario: ErrorScenario) : Attempt
}
private fun ErrorScenario.toDataError(): DataError.Network = when (this) {
ErrorScenario.NO_INTERNET -> DataError.Network.NO_INTERNET
ErrorScenario.NOT_FOUND -> DataError.Network.NOT_FOUND
ErrorScenario.SERVER_ERROR -> DataError.Network.SERVER_ERROR
}
private companion object {
const val LOAD_DELAY_MS = 400L
}
}

View File

@@ -1,12 +1,19 @@
package com.example.architecture.feature.characters.presentation.di
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module {
// Stateless domain UseCase — `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule. See koin-constructor-dsl.
factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}