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:
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterDetailAction {
|
||||
data object OnRetry : CharacterDetailAction
|
||||
data object OnBackClick : CharacterDetailAction
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = "—"
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user