From dd4576409d24faf55b3d2fdcf4b10ac611180409 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:48:21 +0200 Subject: [PATCH] feat(characters:presentation-compose): list renderer, Root/Screen split, previews (REDI-88) - CharacterListRoot: koinViewModel(), ObserveAsEvents, forwards nav + shows snackbar via Context asString. - CharacterListScreen: pure state+onAction; AppScaffold + LazyColumn (key=id), design-system NetworkImage (contentDescription)/AppCard, loading/error/empty states, snapshot-based scroll-to-end -> OnLoadNextPage (ViewModel guards duplicates). - Loaded + error previews wrapped in AppTheme. - feature:characters:presentation now exposes kotlinx-immutable as api (ImmutableList is in the state API). --- .../compose/CharacterListScreen.kt | 263 ++++++++++++++++++ .../src/main/res/values/strings.xml | 8 + .../characters/presentation/build.gradle.kts | 3 +- 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt create mode 100644 feature/characters/presentation-compose/src/main/res/values/strings.xml 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 new file mode 100644 index 0000000..091ec3e --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -0,0 +1,263 @@ +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.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.LaunchedEffect +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 +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.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.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.model.CharacterUi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch +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. + */ +@Composable +fun CharacterListRoot( + onCharacterClick: (Int) -> Unit, + viewModel: CharacterListViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId) + is CharacterListEvent.ShowSnackbar -> scope.launch { + snackbarHostState.showSnackbar(event.message.asString(context)) + } + } + } + + CharacterListScreen( + state = state, + onAction = viewModel::onAction, + snackbarHostState = snackbarHostState, + ) +} + +/** Pure, stateless screen — previewable without a ViewModel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharacterListScreen( + state: CharacterListState, + onAction: (CharacterListAction) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + AppScaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Local val so the nullable cross-module `error` can smart-cast inside the branch. + val error = state.error + when { + state.isLoading -> LoadingIndicator() + + error != null && state.characters.isEmpty() -> ErrorState( + message = error.asString(), + onRetry = { onAction(CharacterListAction.OnRetry) }, + ) + + state.characters.isEmpty() -> EmptyState() + + else -> CharacterList(state = state, onAction = onAction) + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun CharacterList( + state: CharacterListState, + onAction: (CharacterListAction) -> Unit, +) { + val listState = rememberLazyListState() + + // Trigger paging from the snapshot-backed list state only; the ViewModel guards against + // duplicate/just-loading/end-reached requests, so the composable stays simple. + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + total > 0 && lastVisible >= total - 1 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) onAction(CharacterListAction.OnLoadNextPage) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = state.characters, key = { it.id }) { character -> + CharacterListItem( + character = character, + onClick = { onAction(CharacterListAction.OnCharacterClick(character.id)) }, + ) + } + if (state.isLoadingNextPage) { + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun CharacterListItem( + character: CharacterUi, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AppCard(modifier = modifier.fillMaxWidth(), onClick = onClick) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = character.imageUrl, + contentDescription = stringResource(R.string.cd_character_avatar, character.name), + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = character.name, style = MaterialTheme.typography.titleMedium) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(character.status.indicatorColor(), CircleShape), + ) + Text( + text = stringResource(character.status.labelRes()) + " · " + character.species, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.characters_empty), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } +} + +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), + CharacterUi(3, "Birdperson", "Bird-Person", "", CharacterStatus.DEAD), +) + +@Preview +@Composable +private fun CharacterListScreenLoadedPreview() { + AppTheme { + CharacterListScreen( + state = CharacterListState(characters = previewCharacters), + onAction = {}, + ) + } +} + +@Preview +@Composable +private fun CharacterListScreenErrorPreview() { + AppTheme { + CharacterListScreen( + state = CharacterListState( + error = com.example.architecture.core.presentation.UiText.DynamicString( + "No internet connection.", + ), + ), + onAction = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml new file mode 100644 index 0000000..0b9f957 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + Characters + No characters to show. + Avatar of %1$s + Alive + Dead + Unknown + diff --git a/feature/characters/presentation/build.gradle.kts b/feature/characters/presentation/build.gradle.kts index 00837dd..e8e8a28 100644 --- a/feature/characters/presentation/build.gradle.kts +++ b/feature/characters/presentation/build.gradle.kts @@ -19,5 +19,6 @@ dependencies { implementation(libs.kotlinx.coroutines.android) // Stable collection for state — makes the list Compose-stable WITHOUT a Compose dependency, // so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime). - implementation(libs.kotlinx.collections.immutable) + // `api` because CharacterListState.characters exposes ImmutableList in the public state API. + api(libs.kotlinx.collections.immutable) }