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>