diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt new file mode 100644 index 0000000..db46a47 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt @@ -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 = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt index 091ec3e..b342f25 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -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 = {}, ) } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt new file mode 100644 index 0000000..f67dc87 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt @@ -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) +} diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt index 3ae9849..2e6c5a3 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt @@ -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 { - CharacterListRoot(onCharacterClick = onCharacterClick) + CharacterListRoot( + onCharacterClick = { characterId -> + navController.navigate(CharacterDetailRoute(characterId)) + }, + onOpenAbout = onOpenAbout, + onOpenViewsList = onOpenViewsList, + ) + } + composable { + // 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() }) } } diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml index 0b9f957..c4c5132 100644 --- a/feature/characters/presentation-compose/src/main/res/values/strings.xml +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -5,4 +5,19 @@ Alive Dead Unknown + + + More options + About + Open as Views + + + Character + Back + Image of %1$s + Type + Gender + Origin + Location + Episodes diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailAction.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailAction.kt new file mode 100644 index 0000000..95dc2df --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailAction.kt @@ -0,0 +1,6 @@ +package com.example.architecture.feature.characters.presentation + +sealed interface CharacterDetailAction { + data object OnRetry : CharacterDetailAction + data object OnBackClick : CharacterDetailAction +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailEvent.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailEvent.kt new file mode 100644 index 0000000..6f28279 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailEvent.kt @@ -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 +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailState.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailState.kt new file mode 100644 index 0000000..35c4967 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailState.kt @@ -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, +) diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModel.kt new file mode 100644 index 0000000..4ca224f --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModel.kt @@ -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()` — + * 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(KEY_CHARACTER_ID)) { + "CharacterDetailRoute.characterId missing from SavedStateHandle" + } + + private val _state = MutableStateFlow(CharacterDetailState()) + val state = _state.asStateFlow() + + private val _events = Channel() + 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" + } +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt index 466b610..e15ec8d 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt @@ -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) } diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterDetailUi.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterDetailUi.kt new file mode 100644 index 0000000..0011db1 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterDetailUi.kt @@ -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 = "—" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f7ac89..a9f9db4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 = [