diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt index b342f25..594519e 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -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 = {}, ) } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt index 2e6c5a3..8098697 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt @@ -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 { @@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph( // CharacterDetailViewModel reads it (keeping that module free of any navigation dependency). CharacterDetailRoot(onNavigateBack = { navController.popBackStack() }) } + composable { + ErrorDemoRoot(onNavigateBack = { navController.popBackStack() }) + } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt new file mode 100644 index 0000000..cf20c44 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt @@ -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 = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml index c4c5132..b676518 100644 --- a/feature/characters/presentation-compose/src/main/res/values/strings.xml +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -10,6 +10,17 @@ More options About Open as Views + Error handling demo + + + Error handling demo + 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. + Force: No internet + Force: Not found + Force: Server error + Load (success) + Loaded successfully ✓ + Pick an action above to see the result here. Character diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt new file mode 100644 index 0000000..ad5a90c --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt @@ -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 +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt new file mode 100644 index 0000000..b124b76 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt @@ -0,0 +1,5 @@ +package com.example.architecture.feature.characters.presentation + +sealed interface ErrorDemoEvent { + data object NavigateBack : ErrorDemoEvent +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt new file mode 100644 index 0000000..d9069bc --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt @@ -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, +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt new file mode 100644 index 0000000..a3764ec --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt @@ -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() + 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 = 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 + } +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt index e15ec8d..084191d 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt @@ -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) }