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).
This commit is contained in:
2026-06-10 12:48:21 +02:00
parent 2a419df43e
commit dd4576409d
3 changed files with 273 additions and 1 deletions

View File

@@ -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 = {},
)
}
}

View File

@@ -0,0 +1,8 @@
<resources>
<string name="characters_title">Characters</string>
<string name="characters_empty">No characters to show.</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="status_alive">Alive</string>
<string name="status_dead">Dead</string>
<string name="status_unknown">Unknown</string>
</resources>

View File

@@ -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)
}