Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.architecture.android.feature)
// For @Serializable type-safe navigation routes.
alias(libs.plugins.architecture.kotlinx.serialization)
}
android {
namespace = "com.example.architecture.feature.characters.presentation.compose"
}
dependencies {
implementation(project(":core:presentation"))
implementation(project(":core:design-system"))
implementation(project(":feature:characters:domain"))
implementation(project(":feature:characters:presentation"))
// Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to
// androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in.
androidTestImplementation(libs.bundles.compose.ui.test)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,81 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import org.junit.Assert.assertTrue
/**
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
* the interaction vocabulary; the test owns the assertions' intent - keeping tests readable and
* resilient to UI structure changes.
*/
class CharacterListRobot(
private val composeRule: ComposeContentTestRule,
private val context: Context,
) {
private val recordedActions = mutableListOf<CharacterListAction>()
fun setContent(state: CharacterListState): CharacterListRobot {
composeRule.setContent {
AppTheme {
CharacterListScreen(
state = state,
onAction = { recordedActions += it },
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
return this
}
fun assertCharacterShown(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).assertIsDisplayed()
return this
}
fun assertEmptyStateShown(): CharacterListRobot {
composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed()
return this
}
fun assertErrorShown(message: String): CharacterListRobot {
composeRule.onNodeWithText(message).assertIsDisplayed()
return this
}
fun assertRetryShown(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).assertIsDisplayed()
return this
}
fun clickCharacter(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).performClick()
return this
}
fun clickRetry(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).performClick()
return this
}
fun assertActionRecorded(action: CharacterListAction): CharacterListRobot {
assertTrue(
"Expected $action to be recorded, but got $recordedActions",
recordedActions.contains(action),
)
return this
}
// The retry label lives in the design-system module; reference its R directly (non-transitive R).
private val retryLabel: String
get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry)
}

View File

@@ -0,0 +1,77 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a
* device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the
* empty + error states, and that user gestures fire the right MVI [CharacterListAction]s.
*/
@RunWith(AndroidJUnit4::class)
class CharacterListScreenTest {
@get:Rule
val composeRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private fun robot() = CharacterListRobot(composeRule, context)
private val loadedState = CharacterListState(
characters = persistentListOf(
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
),
)
@Test
fun rendersCharacterItems() {
robot()
.setContent(loadedState)
.assertCharacterShown("Rick Sanchez")
.assertCharacterShown("Morty Smith")
}
@Test
fun showsEmptyState() {
robot()
.setContent(CharacterListState())
.assertEmptyStateShown()
}
@Test
fun showsErrorStateWithRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.assertErrorShown("Boom")
.assertRetryShown()
}
@Test
fun tappingAnItemFiresOnCharacterClick() {
robot()
.setContent(loadedState)
.clickCharacter("Rick Sanchez")
.assertActionRecorded(CharacterListAction.OnCharacterClick(1))
}
@Test
fun tappingRetryFiresOnRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.clickRetry()
.assertActionRecorded(CharacterListAction.OnRetry)
}
}

View File

@@ -0,0 +1,227 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.layout.ContentScale
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.component.NetworkImage
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.domain.model.CharacterStatus
import com.example.architecture.feature.characters.presentation.CharacterDetailAction
import com.example.architecture.feature.characters.presentation.CharacterDetailEvent
import com.example.architecture.feature.characters.presentation.CharacterDetailState
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
import org.koin.androidx.compose.koinViewModel
/**
* Root: owns the detail ViewModel (Koin supplies it the route's `characterId` via SavedStateHandle),
* observes the one-time [CharacterDetailEvent.NavigateBack], and forwards "go back" up the nav stack.
*/
@Composable
fun CharacterDetailRoot(
onNavigateBack: () -> Unit,
viewModel: CharacterDetailViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
CharacterDetailEvent.NavigateBack -> onNavigateBack()
}
}
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterDetailScreen(
state: CharacterDetailState,
onAction: (CharacterDetailAction) -> Unit,
) {
AppScaffold(
topBar = {
TopAppBar(
title = {
Text(state.details?.name ?: stringResource(R.string.character_detail_title))
},
navigationIcon = {
IconButton(onClick = { onAction(CharacterDetailAction.OnBackClick) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
val error = state.error
val details = state.details
when {
state.isLoading -> LoadingIndicator()
// Error wins over any (now-cleared) details so a failed load can't show stale content.
error != null -> ErrorState(
message = error.asString(),
onRetry = { onAction(CharacterDetailAction.OnRetry) },
)
details != null -> CharacterDetailContent(details)
}
}
}
}
@Composable
private fun CharacterDetailContent(details: CharacterDetailUi) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
NetworkImage(
imageUrl = details.imageUrl,
contentDescription = stringResource(R.string.cd_character_image, details.name),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(text = details.name, style = MaterialTheme.typography.headlineSmall)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(10.dp)
.background(details.status.indicatorColor(), CircleShape),
)
Text(
text = stringResource(details.status.labelRes()) + " · " + details.species,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
AttributeRow(label = stringResource(R.string.detail_type), value = details.type)
AttributeRow(label = stringResource(R.string.detail_gender), value = details.gender)
AttributeRow(label = stringResource(R.string.detail_origin), value = details.origin)
AttributeRow(label = stringResource(R.string.detail_location), value = details.location)
AttributeRow(
label = stringResource(R.string.detail_episodes),
value = details.episodeCount.toString(),
)
}
}
}
@Composable
private fun AttributeRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
)
}
}
private val previewDetails = CharacterDetailUi(
id = 1,
name = "Rick Sanchez",
status = CharacterStatus.ALIVE,
species = "Human",
type = "",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "",
episodeCount = 51,
)
@Preview
@Composable
private fun CharacterDetailScreenLoadedPreview() {
AppTheme {
CharacterDetailScreen(state = CharacterDetailState(details = previewDetails), onAction = {})
}
}
@Preview
@Composable
private fun CharacterDetailScreenLoadingPreview() {
AppTheme {
CharacterDetailScreen(state = CharacterDetailState(isLoading = true), onAction = {})
}
}
@Preview
@Composable
private fun CharacterDetailScreenErrorPreview() {
AppTheme {
CharacterDetailScreen(
state = CharacterDetailState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"Failed to load character details.",
),
),
onAction = {},
)
}
}

View File

@@ -0,0 +1,325 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
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.AppCard
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.component.NetworkImage
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.domain.model.CharacterStatus
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListEvent
import com.example.architecture.feature.characters.presentation.CharacterListState
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
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], [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()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
ObserveAsEvents(viewModel.events) { event ->
when (event) {
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
is CharacterListEvent.ShowSnackbar -> scope.launch {
snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
CharacterListScreen(
state = state,
onAction = viewModel::onAction,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
snackbarHostState = snackbarHostState,
)
}
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterListScreen(
state: CharacterListState,
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,
onOpenErrorDemo = onOpenErrorDemo,
)
},
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// Local val so the nullable cross-module `error` can smart-cast inside the branch.
val error = state.error
when {
state.isLoading -> LoadingIndicator()
error != null && state.characters.isEmpty() -> ErrorState(
message = error.asString(),
onRetry = { onAction(CharacterListAction.OnRetry) },
)
state.characters.isEmpty() -> EmptyState()
else -> CharacterList(state = state, onAction = onAction)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
@Composable
private fun CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_more_options),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_open_as_views)) },
onClick = {
expanded = false
onOpenViewsList()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_error_demo)) },
onClick = {
expanded = false
onOpenErrorDemo()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_about)) },
onClick = {
expanded = false
onOpenAbout()
},
)
}
}
@Composable
private fun CharacterList(
state: CharacterListState,
onAction: (CharacterListAction) -> Unit,
) {
val listState = rememberLazyListState()
// Trigger paging from the snapshot-backed list state only; the ViewModel guards against
// duplicate/just-loading/end-reached requests, so the composable stays simple.
val shouldLoadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val total = layoutInfo.totalItemsCount
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
total > 0 && lastVisible >= total - 1
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) onAction(CharacterListAction.OnLoadNextPage)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = state.characters, key = { it.id }) { character ->
CharacterListItem(
character = character,
onClick = { onAction(CharacterListAction.OnCharacterClick(character.id)) },
)
}
if (state.isLoadingNextPage) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun CharacterListItem(
character: CharacterUi,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AppCard(modifier = modifier.fillMaxWidth(), onClick = onClick) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetworkImage(
imageUrl = character.imageUrl,
contentDescription = stringResource(R.string.cd_character_avatar, character.name),
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = character.name, style = MaterialTheme.typography.titleMedium)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.background(character.status.indicatorColor(), CircleShape),
)
Text(
text = stringResource(character.status.labelRes()) + " · " + character.species,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun EmptyState() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.characters_empty),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
private val previewCharacters = persistentListOf(
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
CharacterUi(3, "Birdperson", "Bird-Person", "", CharacterStatus.DEAD),
)
@Preview
@Composable
private fun CharacterListScreenLoadedPreview() {
AppTheme {
CharacterListScreen(
state = CharacterListState(characters = previewCharacters),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
@Preview
@Composable
private fun CharacterListScreenErrorPreview() {
AppTheme {
CharacterListScreen(
state = CharacterListState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"No internet connection.",
),
),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}

View File

@@ -0,0 +1,19 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Shared Compose presentation helpers for [CharacterStatus], used by both the list and detail screens. */
@StringRes
internal fun CharacterStatus.labelRes(): Int = when (this) {
CharacterStatus.ALIVE -> R.string.status_alive
CharacterStatus.DEAD -> R.string.status_dead
CharacterStatus.UNKNOWN -> R.string.status_unknown
}
internal fun CharacterStatus.indicatorColor(): Color = when (this) {
CharacterStatus.ALIVE -> Color(0xFF4CAF50)
CharacterStatus.DEAD -> Color(0xFFE53935)
CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E)
}

View File

@@ -0,0 +1,49 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
/** Type-safe route for the characters list screen. */
@Serializable
data object CharacterListRoute
/** Type-safe route for the character detail screen - carries only the typed id, never an object. */
@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 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,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
) {
composable<CharacterListRoute> {
CharacterListRoot(
onCharacterClick = { characterId ->
navController.navigate(CharacterDetailRoute(characterId))
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
)
}
composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
// which Navigation copies into the ViewModel's SavedStateHandle - that is where
// 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

@@ -0,0 +1,34 @@
<resources>
<string name="characters_title">Characters</string>
<string name="characters_empty">No characters to show.</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="status_alive">Alive</string>
<string name="status_dead">Dead</string>
<string name="status_unknown">Unknown</string>
<!-- Overflow menu -->
<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>
<string name="cd_back">Back</string>
<string name="cd_character_image">Image of %1$s</string>
<string name="detail_type">Type</string>
<string name="detail_gender">Gender</string>
<string name="detail_origin">Origin</string>
<string name="detail_location">Location</string>
<string name="detail_episodes">Episodes</string>
</resources>