From 33de7f5ef84146b0caccd689f4aced5e938ed7d5 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:44:39 +0200 Subject: [PATCH 1/5] 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. --- .../compose/CharacterDetailScreen.kt | 227 ++++++++++++++++++ .../compose/CharacterListScreen.kt | 77 ++++-- .../presentation/compose/CharacterStatusUi.kt | 19 ++ .../compose/CharactersNavigation.kt | 28 ++- .../src/main/res/values/strings.xml | 15 ++ .../presentation/CharacterDetailAction.kt | 6 + .../presentation/CharacterDetailEvent.kt | 6 + .../presentation/CharacterDetailState.kt | 14 ++ .../presentation/CharacterDetailViewModel.kt | 73 ++++++ .../di/CharactersPresentationModule.kt | 4 +- .../presentation/model/CharacterDetailUi.kt | 36 +++ gradle/libs.versions.toml | 2 + 12 files changed, 485 insertions(+), 22 deletions(-) create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailAction.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailEvent.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailState.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModel.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterDetailUi.kt 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 = [ From 5f2792002b5573d427ac0dff25470e4a6fe6819d Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:44:47 +0200 Subject: [PATCH 2/5] REDI-91: MVVM contrast screen (:feature:about:presentation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deliberately small MVVM About screen: AboutViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote) with NO Action sealed type and NO Event channel — the explicit contrast to the app's MVI screens. AboutScreen collects state and calls the VM method directly; links open via LocalUriHandler. Static showcase copy lives in the VM as state to demonstrate the StateFlow-as-content shape. aboutGraph + @Serializable AboutRoute + aboutPresentationModule wire it in. A code comment and the README explain why this is MVVM and when each pattern fits. --- feature/about/presentation/build.gradle.kts | 2 + .../about/presentation/AboutNavigation.kt | 21 +++ .../feature/about/presentation/AboutScreen.kt | 140 ++++++++++++++++++ .../feature/about/presentation/AboutState.kt | 22 +++ .../about/presentation/AboutViewModel.kt | 63 ++++++++ .../di/AboutPresentationModule.kt | 10 ++ .../about/presentation/model/AboutLink.kt | 7 + .../src/main/res/values/strings.xml | 8 + 8 files changed, 273 insertions(+) create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt create mode 100644 feature/about/presentation/src/main/res/values/strings.xml diff --git a/feature/about/presentation/build.gradle.kts b/feature/about/presentation/build.gradle.kts index 444397e..25882c0 100644 --- a/feature/about/presentation/build.gradle.kts +++ b/feature/about/presentation/build.gradle.kts @@ -1,5 +1,7 @@ plugins { alias(libs.plugins.architecture.android.feature) + // For @Serializable type-safe navigation routes. + alias(libs.plugins.architecture.kotlinx.serialization) } // MVVM contrast screen (StateFlow + plain VM methods, no Action/Event funnel). Static content, diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt new file mode 100644 index 0000000..9462541 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt @@ -0,0 +1,21 @@ +package com.example.architecture.feature.about.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +/** Type-safe route for the About screen. */ +@Serializable +data object AboutRoute + +/** + * The About feature nav graph. It only needs a "go back" callback — `:app` wires it to the shared + * NavController, keeping this feature decoupled from how it is reached. + */ +fun NavGraphBuilder.aboutGraph( + onNavigateBack: () -> Unit, +) { + composable { + AboutRoot(onNavigateBack = onNavigateBack) + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt new file mode 100644 index 0000000..daf2caa --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt @@ -0,0 +1,140 @@ +package com.example.architecture.feature.about.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +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.AppCard +import com.example.architecture.core.design.system.component.AppScaffold +import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.feature.about.presentation.model.AboutLink + +/** + * Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects + * [AboutState] and passes the ViewModel's **method reference** straight through — there is no + * `onAction` funnel and no event observation, because this screen has neither. + */ +@Composable +fun AboutRoot( + onNavigateBack: () -> Unit, + viewModel: AboutViewModel = org.koin.androidx.compose.koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + AboutScreen( + state = state, + onToggleMvvmNote = viewModel::onToggleMvvmNote, + onNavigateBack = onNavigateBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + state: AboutState, + onToggleMvvmNote: () -> Unit, + onNavigateBack: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + AppScaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.about_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = state.appName, style = MaterialTheme.typography.headlineSmall) + Text(text = state.description, style = MaterialTheme.typography.bodyLarge) + + Text( + text = stringResource(R.string.about_architecture_header), + style = MaterialTheme.typography.titleMedium, + ) + state.architectureHighlights.forEach { highlight -> + Text(text = "• $highlight", style = MaterialTheme.typography.bodyMedium) + } + + // The expandable card is driven entirely by the VM's plain method — the MVVM contrast. + AppCard( + onClick = onToggleMvvmNote, + header = { + Text( + text = stringResource(R.string.about_mvvm_header), + style = MaterialTheme.typography.titleMedium, + ) + }, + ) { + Text( + text = if (state.showMvvmNote) state.mvvmNote else stringResource(R.string.about_mvvm_hint), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Text( + text = stringResource(R.string.about_links_header), + style = MaterialTheme.typography.titleMedium, + ) + state.links.forEach { link -> + TextButton(onClick = { uriHandler.openUri(link.url) }) { + Text(text = link.label) + } + } + } + } +} + +@Preview +@Composable +private fun AboutScreenPreview() { + AppTheme { + AboutScreen( + state = AboutState( + appName = "Android Architecture Showcase", + description = "A reference Android app demonstrating a modern multi-module architecture.", + architectureHighlights = listOf( + "Multi-module Clean Architecture.", + "MVI primary, MVVM contrast.", + ), + mvvmNote = "MVI funnels intents through onAction; this screen uses plain VM methods.", + showMvvmNote = true, + links = listOf(AboutLink("GitHub repository", "https://example.com")), + ), + onToggleMvvmNote = {}, + onNavigateBack = {}, + ) + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt new file mode 100644 index 0000000..5d4f4de --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt @@ -0,0 +1,22 @@ +package com.example.architecture.feature.about.presentation + +import androidx.compose.runtime.Stable +import com.example.architecture.feature.about.presentation.model.AboutLink + +/** + * State for the MVVM About screen. + * + * Contrast with the MVI [com.example.architecture.feature.characters.presentation.CharacterListState]: + * that one is UI-agnostic and stays Compose-free by using `ImmutableList`. This module is a + * Compose-only presentation layer, so it simply annotates the state `@Stable` (cheaper than pulling + * in kotlinx-collections-immutable) to keep the `List` fields from defeating recomposition skipping. + */ +@Stable +data class AboutState( + val appName: String = "", + val description: String = "", + val architectureHighlights: List = emptyList(), + val mvvmNote: String = "", + val showMvvmNote: Boolean = false, + val links: List = emptyList(), +) diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt new file mode 100644 index 0000000..3543016 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt @@ -0,0 +1,63 @@ +package com.example.architecture.feature.about.presentation + +import androidx.lifecycle.ViewModel +import com.example.architecture.feature.about.presentation.model.AboutLink +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * **MVVM — the deliberate contrast to the app's MVI screens.** + * + * There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and + * invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen: + * for small, mostly-static UI, the MVI ceremony (single `onAction` funnel + one-time event channel) + * isn't worth it. See [AboutState] for the matching stability note, and the in-app "Why is this + * screen MVVM?" card / the README for when to pick each pattern. + * + * The showcase copy lives here as state (rather than in string resources) precisely to demonstrate + * the "StateFlow holds the content" MVVM shape; real localizable product copy would use resources. + */ +class AboutViewModel : ViewModel() { + + private val _state = MutableStateFlow( + AboutState( + appName = "Android Architecture Showcase", + description = "A reference Android app that demonstrates a modern, multi-module " + + "architecture: feature-layered Clean Architecture, a typed networking + error stack, " + + "and a single presentation layer rendered by two different UI toolkits.", + architectureHighlights = listOf( + "Multi-module, feature-layered Clean Architecture (presentation → domain ← data).", + "Gradle convention plugins with a single version catalog as the source of truth.", + "MVI is the primary pattern; this About screen is the MVVM contrast.", + "One UI-agnostic ViewModel rendered by both Jetpack Compose and classic Android Views.", + "Koin for DI, Ktor for networking, type-safe Compose Navigation, Coil for images.", + "Typed Result / DataError handling surfaced to the UI as UiText.", + ), + mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " + + "and emits one-time effects (navigation, snackbars) through an Event channel. That " + + "structure pays off when state is complex and interacting — like the paginated, " + + "process-death-restorable characters list. This screen is intentionally MVVM instead: " + + "the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " + + "no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " + + "and side effects matter; reach for MVVM when the screen is small and mostly static.", + links = listOf( + AboutLink( + label = "GitHub repository", + url = "https://github.com/AdrianKuta/android-architecture-showcase", + ), + AboutLink( + label = "Rick & Morty API (data source)", + url = "https://rickandmortyapi.com", + ), + ), + ), + ) + val state: StateFlow = _state.asStateFlow() + + /** MVVM: a plain public method mutates state directly — no Action object, no reducer funnel. */ + fun onToggleMvvmNote() { + _state.update { it.copy(showMvvmNote = !it.showMvvmNote) } + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt new file mode 100644 index 0000000..8732ed9 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt @@ -0,0 +1,10 @@ +package com.example.architecture.feature.about.presentation.di + +import com.example.architecture.feature.about.presentation.AboutViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +/** Presentation DI for the About feature. */ +val aboutPresentationModule = module { + viewModelOf(::AboutViewModel) +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt new file mode 100644 index 0000000..9e9cff7 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt @@ -0,0 +1,7 @@ +package com.example.architecture.feature.about.presentation.model + +/** A labelled external link shown on the About screen. */ +data class AboutLink( + val label: String, + val url: String, +) diff --git a/feature/about/presentation/src/main/res/values/strings.xml b/feature/about/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000..46a6616 --- /dev/null +++ b/feature/about/presentation/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + About + Architecture highlights + Why is this screen MVVM? + Tap to see how this MVVM screen differs from the app\'s MVI screens. + Links + Back + From e230aa77d8849c8f117e161a637ccb31db2616de Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:44:53 +0200 Subject: [PATCH 3/5] REDI-92: Views/XML renderer for the characters list (same MVI ViewModel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add :feature:characters:presentation-views — a classic Fragment + ViewBinding + RecyclerView/DiffUtil renderer driving the SAME CharacterListViewModel as the Compose screen (obtained via Koin's by viewModel()), proving the presentation logic is truly UI-agnostic. State is observed with viewLifecycleOwner.repeatOnLifecycle(STARTED), one-time Events are collected, UiText is resolved via Context, and the binding is nulled in onDestroyView. Coil loads avatars into ImageView with a circle-crop transform; the module has no Compose dependency. Paging scroll listener guards the empty-list case (lastVisible >= 0), uses a safe layoutManager cast, and is removed in onDestroyView. --- .../views/CharacterListAdapter.kt | 74 ++++++++++ .../views/CharacterListFragment.kt | 127 ++++++++++++++++++ .../views/CharacterStatusViews.kt | 24 ++++ .../src/main/res/drawable/bg_status_dot.xml | 6 + .../src/main/res/drawable/ic_arrow_back.xml | 11 ++ .../res/layout/fragment_character_list.xml | 71 ++++++++++ .../src/main/res/layout/item_character.xml | 54 ++++++++ .../src/main/res/values/strings.xml | 9 ++ 8 files changed, 376 insertions(+) create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt create mode 100644 feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml create mode 100644 feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml create mode 100644 feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml create mode 100644 feature/characters/presentation-views/src/main/res/layout/item_character.xml create mode 100644 feature/characters/presentation-views/src/main/res/values/strings.xml diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt new file mode 100644 index 0000000..6e116e9 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt @@ -0,0 +1,74 @@ +package com.example.architecture.feature.characters.presentation.views + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import coil3.request.crossfade +import coil3.request.transformations +import coil3.transform.CircleCropTransformation +import com.example.architecture.feature.characters.presentation.model.CharacterUi +import com.example.architecture.feature.characters.presentation.views.databinding.ItemCharacterBinding + +/** + * RecyclerView adapter over the SAME [CharacterUi] presentation model the Compose renderer uses. + * [DiffUtil] computes minimal updates; Coil loads avatars straight into the `ImageView`. + */ +internal class CharacterListAdapter( + private val onItemClick: (Int) -> Unit, +) : ListAdapter(DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder { + val binding = ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return CharacterViewHolder(binding) + } + + override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class CharacterViewHolder( + private val binding: ItemCharacterBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onItemClick(getItem(position).id) + } + } + } + + fun bind(item: CharacterUi) { + val context = binding.root.context + binding.name.text = item.name + binding.statusSpecies.text = + context.getString(item.status.labelRes()) + " · " + item.species + ViewCompat.setBackgroundTintList( + binding.statusDot, + ColorStateList.valueOf(item.status.indicatorColor()), + ) + binding.avatar.contentDescription = + context.getString(R.string.cd_character_avatar, item.name) + binding.avatar.load(item.imageUrl) { + crossfade(true) + transformations(CircleCropTransformation()) + } + } + } + + private companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem == newItem + } + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt new file mode 100644 index 0000000..a7543a0 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt @@ -0,0 +1,127 @@ +package com.example.architecture.feature.characters.presentation.views + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.architecture.core.presentation.asString +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListEvent +import com.example.architecture.feature.characters.presentation.CharacterListState +import com.example.architecture.feature.characters.presentation.CharacterListViewModel +import com.example.architecture.feature.characters.presentation.views.databinding.FragmentCharacterListBinding +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel + +/** + * Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as + * the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly + * UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`). + * + * `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches + * the Compose NavController, so this module stays decoupled from navigation. + */ +class CharacterListFragment : Fragment() { + + var onCharacterClick: (Int) -> Unit = {} + var onNavigateBack: () -> Unit = {} + + private var _binding: FragmentCharacterListBinding? = null + private val binding get() = _binding!! + + private val viewModel: CharacterListViewModel by viewModel() + + private lateinit var listAdapter: CharacterListAdapter + private var pagingListener: RecyclerView.OnScrollListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentCharacterListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listAdapter = CharacterListAdapter( + onItemClick = { id -> viewModel.onAction(CharacterListAction.OnCharacterClick(id)) }, + ) + val scrollListener = pagingScrollListener() + pagingListener = scrollListener + binding.recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = listAdapter + addOnScrollListener(scrollListener) + } + binding.toolbar.setNavigationOnClickListener { onNavigateBack() } + binding.retryButton.setOnClickListener { + viewModel.onAction(CharacterListAction.OnRetry) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.state.collect(::render) } + launch { viewModel.events.collect(::handleEvent) } + } + } + } + + private fun render(state: CharacterListState) { + listAdapter.submitList(state.characters) + + val showFullScreenError = state.error != null && state.characters.isEmpty() + binding.progressBar.isVisible = state.isLoading + binding.nextPageProgress.isVisible = state.isLoadingNextPage + binding.errorContainer.isVisible = showFullScreenError + binding.recyclerView.isVisible = !state.isLoading && !showFullScreenError + + if (showFullScreenError) { + binding.errorMessage.text = state.error?.asString(requireContext()) + } + } + + private fun handleEvent(event: CharacterListEvent) { + when (event) { + is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId) + is CharacterListEvent.ShowSnackbar -> Snackbar.make( + binding.root, + event.message.asString(requireContext()), + Snackbar.LENGTH_SHORT, + ).show() + } + } + + private fun pagingScrollListener() = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return + val lastVisible = layoutManager.findLastVisibleItemPosition() + // `lastVisible >= 0` skips the empty-list case (findLastVisibleItemPosition() == -1), + // mirroring the Compose renderer's `total > 0` guard. The ViewModel still guards against + // duplicate / end-reached / already-loading requests. + if (lastVisible >= 0 && lastVisible >= layoutManager.itemCount - 1) { + viewModel.onAction(CharacterListAction.OnLoadNextPage) + } + } + } + + override fun onDestroyView() { + // Remove the scroll listener and detach the adapter before nulling the binding so neither + // the RecyclerView nor this Fragment is leaked. + pagingListener?.let { binding.recyclerView.removeOnScrollListener(it) } + pagingListener = null + binding.recyclerView.adapter = null + super.onDestroyView() + _binding = null + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt new file mode 100644 index 0000000..23d7f80 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt @@ -0,0 +1,24 @@ +package com.example.architecture.feature.characters.presentation.views + +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import com.example.architecture.feature.characters.domain.model.CharacterStatus + +/** + * Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose + * renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer + * owns its own resources, so the small label duplication across modules is expected. + */ +@StringRes +internal fun CharacterStatus.labelRes(): Int = when (this) { + CharacterStatus.ALIVE -> R.string.characters_views_status_alive + CharacterStatus.DEAD -> R.string.characters_views_status_dead + CharacterStatus.UNKNOWN -> R.string.characters_views_status_unknown +} + +@ColorInt +internal fun CharacterStatus.indicatorColor(): Int = when (this) { + CharacterStatus.ALIVE -> 0xFF4CAF50.toInt() + CharacterStatus.DEAD -> 0xFFE53935.toInt() + CharacterStatus.UNKNOWN -> 0xFF9E9E9E.toInt() +} diff --git a/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml new file mode 100644 index 0000000..8eb9180 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..9cac029 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml new file mode 100644 index 0000000..57f148f --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + +