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,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 = ""