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:
@@ -66,14 +66,15 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
|
* 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.
|
* 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
|
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow
|
||||||
* are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
|
* menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CharacterListRoot(
|
fun CharacterListRoot(
|
||||||
onCharacterClick: (Int) -> Unit,
|
onCharacterClick: (Int) -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
viewModel: CharacterListViewModel = koinViewModel(),
|
viewModel: CharacterListViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -95,6 +96,7 @@ fun CharacterListRoot(
|
|||||||
onAction = viewModel::onAction,
|
onAction = viewModel::onAction,
|
||||||
onOpenAbout = onOpenAbout,
|
onOpenAbout = onOpenAbout,
|
||||||
onOpenViewsList = onOpenViewsList,
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,13 +109,20 @@ fun CharacterListScreen(
|
|||||||
onAction: (CharacterListAction) -> Unit,
|
onAction: (CharacterListAction) -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
) {
|
) {
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.characters_title)) },
|
title = { Text(stringResource(R.string.characters_title)) },
|
||||||
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
|
actions = {
|
||||||
|
CharacterListOverflowMenu(
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@@ -149,6 +158,7 @@ fun CharacterListScreen(
|
|||||||
private fun CharacterListOverflowMenu(
|
private fun CharacterListOverflowMenu(
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
IconButton(onClick = { expanded = true }) {
|
IconButton(onClick = { expanded = true }) {
|
||||||
@@ -165,6 +175,13 @@ private fun CharacterListOverflowMenu(
|
|||||||
onOpenViewsList()
|
onOpenViewsList()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_error_demo)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onOpenErrorDemo()
|
||||||
|
},
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_about)) },
|
text = { Text(stringResource(R.string.menu_about)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -284,6 +301,7 @@ private fun CharacterListScreenLoadedPreview() {
|
|||||||
onAction = {},
|
onAction = {},
|
||||||
onOpenAbout = {},
|
onOpenAbout = {},
|
||||||
onOpenViewsList = {},
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +319,7 @@ private fun CharacterListScreenErrorPreview() {
|
|||||||
onAction = {},
|
onAction = {},
|
||||||
onOpenAbout = {},
|
onOpenAbout = {},
|
||||||
onOpenViewsList = {},
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ data object CharacterListRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class CharacterDetailRoute(val characterId: Int)
|
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
|
* The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so
|
||||||
* [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted
|
* they are driven by the [navController] passed in. Cross-boundary destinations (the About screen,
|
||||||
* by `:app`) stay decoupled as callbacks supplied by `:app`.
|
* the Views renderer hosted by `:app`) stay decoupled as callbacks supplied by `:app`.
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.charactersGraph(
|
fun NavGraphBuilder.charactersGraph(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
@@ -30,6 +34,7 @@ fun NavGraphBuilder.charactersGraph(
|
|||||||
},
|
},
|
||||||
onOpenAbout = onOpenAbout,
|
onOpenAbout = onOpenAbout,
|
||||||
onOpenViewsList = onOpenViewsList,
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable<CharacterDetailRoute> {
|
composable<CharacterDetailRoute> {
|
||||||
@@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph(
|
|||||||
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
|
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
|
||||||
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
|
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable<ErrorDemoRoute> {
|
||||||
|
ErrorDemoRoot(onNavigateBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
<string name="cd_more_options">More options</string>
|
<string name="cd_more_options">More options</string>
|
||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_open_as_views">Open as Views</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 -->
|
<!-- Detail screen -->
|
||||||
<string name="character_detail_title">Character</string>
|
<string name="character_detail_title">Character</string>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
sealed interface ErrorDemoEvent {
|
||||||
|
data object NavigateBack : ErrorDemoEvent
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package com.example.architecture.feature.characters.presentation.di
|
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.CharacterDetailViewModel
|
||||||
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
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.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
|
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
|
||||||
val charactersPresentationModule = module {
|
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(::CharacterListViewModel)
|
||||||
viewModelOf(::CharacterDetailViewModel)
|
viewModelOf(::CharacterDetailViewModel)
|
||||||
|
viewModelOf(::ErrorDemoViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user