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

@@ -66,14 +66,15 @@ import org.koin.androidx.compose.koinViewModel
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
*
* [onOpenAbout] and [onOpenViewsList] are renderer-only chrome (a Compose overflow menu), so they
* are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow
* menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
*/
@Composable
fun CharacterListRoot(
onCharacterClick: (Int) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
viewModel: CharacterListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -95,6 +96,7 @@ fun CharacterListRoot(
onAction = viewModel::onAction,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
snackbarHostState = snackbarHostState,
)
}
@@ -107,13 +109,20 @@ fun CharacterListScreen(
onAction: (CharacterListAction) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.characters_title)) },
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
actions = {
CharacterListOverflowMenu(
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
)
},
)
},
) { innerPadding ->
@@ -149,6 +158,7 @@ fun CharacterListScreen(
private fun CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
@@ -165,6 +175,13 @@ private fun CharacterListOverflowMenu(
onOpenViewsList()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_error_demo)) },
onClick = {
expanded = false
onOpenErrorDemo()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_about)) },
onClick = {
@@ -284,6 +301,7 @@ private fun CharacterListScreenLoadedPreview() {
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
@@ -301,6 +319,7 @@ private fun CharacterListScreenErrorPreview() {
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}

View File

@@ -13,10 +13,14 @@ data object CharacterListRoute
@Serializable
data class CharacterDetailRoute(val characterId: Int)
/** Type-safe route for the error-handling demo screen. */
@Serializable
data object ErrorDemoRoute
/**
* The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the
* [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted
* by `:app`) stay decoupled as callbacks supplied by `:app`.
* The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so
* they are driven by the [navController] passed in. Cross-boundary destinations (the About screen,
* the Views renderer hosted by `:app`) stay decoupled as callbacks supplied by `:app`.
*/
fun NavGraphBuilder.charactersGraph(
navController: NavController,
@@ -30,6 +34,7 @@ fun NavGraphBuilder.charactersGraph(
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
)
}
composable<CharacterDetailRoute> {
@@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph(
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
}
composable<ErrorDemoRoute> {
ErrorDemoRoot(onNavigateBack = { navController.popBackStack() })
}
}

View File

@@ -0,0 +1,156 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.lifecycle.compose.collectAsStateWithLifecycle
import com.example.architecture.core.design.system.component.AppScaffold
import com.example.architecture.core.design.system.component.ErrorState
import com.example.architecture.core.design.system.component.LoadingIndicator
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.core.presentation.ObserveAsEvents
import com.example.architecture.core.presentation.asString
import com.example.architecture.feature.characters.presentation.ErrorDemoAction
import com.example.architecture.feature.characters.presentation.ErrorDemoEvent
import com.example.architecture.feature.characters.presentation.ErrorDemoState
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import com.example.architecture.feature.characters.presentation.ErrorScenario
import org.koin.androidx.compose.koinViewModel
/**
* Root: owns the demo ViewModel (Koin) and forwards the one-time NavigateBack event up the stack.
*/
@Composable
fun ErrorDemoRoot(
onNavigateBack: () -> Unit,
viewModel: ErrorDemoViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
ErrorDemoEvent.NavigateBack -> onNavigateBack()
}
}
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen — previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDemoScreen(
state: ErrorDemoState,
onAction: (ErrorDemoAction) -> Unit,
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.error_demo_title)) },
navigationIcon = {
IconButton(onClick = { onAction(ErrorDemoAction.OnBackClick) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.error_demo_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NO_INTERNET)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_no_internet)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NOT_FOUND)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_not_found)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.SERVER_ERROR)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_server)) }
Button(
onClick = { onAction(ErrorDemoAction.OnLoadSuccess) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_load_success)) }
// Result area: loading → mapped error (with retry) → success → idle hint.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val error = state.error
when {
state.isLoading -> LoadingIndicator()
error != null -> ErrorState(
message = error.asString(),
onRetry = { onAction(ErrorDemoAction.OnRetry) },
)
state.loaded -> Text(
text = stringResource(R.string.error_demo_success),
style = MaterialTheme.typography.titleMedium,
)
else -> Text(
text = stringResource(R.string.error_demo_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Preview
@Composable
private fun ErrorDemoScreenIdlePreview() {
AppTheme { ErrorDemoScreen(state = ErrorDemoState(), onAction = {}) }
}
@Preview
@Composable
private fun ErrorDemoScreenErrorPreview() {
AppTheme {
ErrorDemoScreen(
state = ErrorDemoState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"No internet connection. Check your network and try again.",
),
),
onAction = {},
)
}
}

View File

@@ -10,6 +10,17 @@
<string name="cd_more_options">More options</string>
<string name="menu_about">About</string>
<string name="menu_open_as_views">Open as Views</string>
<string name="menu_error_demo">Error handling demo</string>
<!-- Error-handling demo screen -->
<string name="error_demo_title">Error handling demo</string>
<string name="error_demo_intro">Force a network failure to watch it flow through the pipeline: DataError.Network → toUiText() → the shared ErrorState. Retry re-issues the same request; a successful load clears the error.</string>
<string name="error_demo_force_no_internet">Force: No internet</string>
<string name="error_demo_force_not_found">Force: Not found</string>
<string name="error_demo_force_server">Force: Server error</string>
<string name="error_demo_load_success">Load (success)</string>
<string name="error_demo_success">Loaded successfully ✓</string>
<string name="error_demo_hint">Pick an action above to see the result here.</string>
<!-- Detail screen -->
<string name="character_detail_title">Character</string>

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)
}