REDI-90: character detail screen (type-safe nav args + MVI)

Add a CharacterDetail MVI stack (State/Action/Event/ViewModel + CharacterDetailUi)
to the UI-agnostic :feature:characters:presentation. The detail ViewModel reads the
typed characterId from SavedStateHandle (populated by the type-safe CharacterDetailRoute),
so the module keeps zero navigation/Compose deps.

Add CharacterDetailScreen (Root/Screen, image header, attribute rows, loading/error)
and CharacterDetailRoute to :presentation-compose; refactor charactersGraph to drive
list->detail via NavController and expose About / Views entries as callbacks. Extract
shared CharacterStatus label/colour helpers; add an overflow menu to the list app bar.

Add material-icons-core to the compose bundle for the app-bar icons.
This commit is contained in:
2026-06-10 13:44:39 +02:00
parent 843c2fb4ef
commit 33de7f5ef8
12 changed files with 485 additions and 22 deletions

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

@@ -3,8 +3,8 @@ 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.PaddingValues
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
@@ -14,8 +14,14 @@ 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
@@ -24,13 +30,14 @@ 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.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -58,10 +65,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.
*/
@Composable
fun CharacterListRoot(
onCharacterClick: (Int) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
viewModel: CharacterListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -81,6 +93,8 @@ fun CharacterListRoot(
CharacterListScreen(
state = state,
onAction = viewModel::onAction,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
snackbarHostState = snackbarHostState,
)
}
@@ -91,10 +105,17 @@ fun CharacterListRoot(
fun CharacterListScreen(
state: CharacterListState,
onAction: (CharacterListAction) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
AppScaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.characters_title)) },
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
)
},
) { innerPadding ->
Box(
modifier = Modifier
@@ -124,6 +145,36 @@ fun CharacterListScreen(
}
}
@Composable
private fun CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> 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_about)) },
onClick = {
expanded = false
onOpenAbout()
},
)
}
}
@Composable
private fun CharacterList(
state: CharacterListState,
@@ -148,7 +199,7 @@ private fun CharacterList(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = state.characters, key = { it.id }) { character ->
@@ -218,18 +269,6 @@ private fun EmptyState() {
}
}
private fun CharacterStatus.labelRes(): Int = when (this) {
CharacterStatus.ALIVE -> R.string.status_alive
CharacterStatus.DEAD -> R.string.status_dead
CharacterStatus.UNKNOWN -> R.string.status_unknown
}
private fun CharacterStatus.indicatorColor(): Color = when (this) {
CharacterStatus.ALIVE -> Color(0xFF4CAF50)
CharacterStatus.DEAD -> Color(0xFFE53935)
CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E)
}
private val previewCharacters = persistentListOf(
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
@@ -243,6 +282,8 @@ private fun CharacterListScreenLoadedPreview() {
CharacterListScreen(
state = CharacterListState(characters = previewCharacters),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
)
}
}
@@ -258,6 +299,8 @@ private fun CharacterListScreenErrorPreview() {
),
),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
)
}
}

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

@@ -1,5 +1,6 @@
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
@@ -8,14 +9,33 @@ import kotlinx.serialization.Serializable
@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)
/**
* The characters feature nav graph. `:app` only calls this and supplies cross-screen navigation as
* a callback. The detail destination is added here in a later milestone.
* 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`.
*/
fun NavGraphBuilder.charactersGraph(
onCharacterClick: (Int) -> Unit,
navController: NavController,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
) {
composable<CharacterListRoute> {
CharacterListRoot(onCharacterClick = onCharacterClick)
CharacterListRoot(
onCharacterClick = { characterId ->
navController.navigate(CharacterDetailRoute(characterId))
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
)
}
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() })
}
}

View File

@@ -5,4 +5,19 @@
<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>
<!-- 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>

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailAction {
data object OnRetry : CharacterDetailAction
data object OnBackClick : CharacterDetailAction
}

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailEvent {
/** One-time effect: the user asked to leave; the renderer pops the back stack. */
data object NavigateBack : CharacterDetailEvent
}

View File

@@ -0,0 +1,14 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
/**
* UI state for the character detail screen. Like [CharacterListState] this is Compose-free: all
* fields are stable types, so no `@Stable` (and therefore no Compose dependency) is needed.
*/
data class CharacterDetailState(
val details: CharacterDetailUi? = null,
val isLoading: Boolean = false,
val error: UiText? = null,
)

View File

@@ -0,0 +1,73 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.architecture.core.domain.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.toUiText
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.presentation.model.toCharacterDetailUi
import kotlinx.coroutines.channels.Channel
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 character detail screen.
*
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
* field name. Reading that raw key — instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` —
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
*/
class CharacterDetailViewModel(
savedStateHandle: SavedStateHandle,
private val characterRepository: CharacterRepository,
) : ViewModel() {
private val characterId: Int = checkNotNull(savedStateHandle.get<Int>(KEY_CHARACTER_ID)) {
"CharacterDetailRoute.characterId missing from SavedStateHandle"
}
private val _state = MutableStateFlow(CharacterDetailState())
val state = _state.asStateFlow()
private val _events = Channel<CharacterDetailEvent>()
val events = _events.receiveAsFlow()
init {
loadDetails()
}
fun onAction(action: CharacterDetailAction) {
when (action) {
CharacterDetailAction.OnRetry -> loadDetails()
CharacterDetailAction.OnBackClick -> viewModelScope.launch {
_events.send(CharacterDetailEvent.NavigateBack)
}
}
}
private fun loadDetails() {
// Clear previous details too: a (re)load must never leave stale content beside a fresh error.
_state.update { it.copy(isLoading = true, error = null, details = null) }
viewModelScope.launch {
characterRepository.getCharacterDetails(characterId)
.onSuccess { details ->
_state.update {
it.copy(details = details.toCharacterDetailUi(), isLoading = false, error = null)
}
}
.onFailure { failure ->
_state.update { it.copy(isLoading = false, error = failure.toUiText()) }
}
}
}
private companion object {
const val KEY_CHARACTER_ID = "characterId"
}
}

View File

@@ -1,10 +1,12 @@
package com.example.architecture.feature.characters.presentation.di
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModel it provides. */
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module {
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
}

View File

@@ -0,0 +1,36 @@
package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Presentation model for the character detail screen. Blank free-text API fields (notably `type`)
* are pre-formatted to an em dash here so the renderer stays dumb.
*/
data class CharacterDetailUi(
val id: Int,
val name: String,
val status: CharacterStatus,
val species: String,
val type: String,
val gender: String,
val origin: String,
val location: String,
val imageUrl: String,
val episodeCount: Int,
)
fun CharacterDetails.toCharacterDetailUi(): CharacterDetailUi = CharacterDetailUi(
id = id,
name = name,
status = status,
species = species.ifBlank { DASH },
type = type.ifBlank { DASH },
gender = gender.ifBlank { DASH },
origin = origin.ifBlank { DASH },
location = location.ifBlank { DASH },
imageUrl = imageUrl,
episodeCount = episodeCount,
)
private const val DASH = ""

View File

@@ -81,6 +81,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# --- Coroutines / serialization ---
@@ -133,6 +134,7 @@ compose = [
"androidx-compose-foundation",
"androidx-compose-ui-tooling-preview",
"androidx-compose-material3",
"androidx-compose-material-icons-core",
]
koin = ["koin-core", "koin-android"]
ktor = [