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,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 = "—"
|
||||
Reference in New Issue
Block a user