Merge pull request #2 from AdrianKuta/feat/flagship-mvi
Flagship MVI Feature (REDI-85…89)
This commit is contained in:
@@ -16,10 +16,16 @@ dependencies {
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:design-system"))
|
||||
|
||||
// Characters feature: data + presentation (Koin modules) + Compose renderer (nav graph).
|
||||
implementation(project(":feature:characters:data"))
|
||||
implementation(project(":feature:characters:presentation"))
|
||||
implementation(project(":feature:characters:presentation-compose"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.bundles.lifecycle.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
// Material Components — required for the Material3 XML Activity theme.
|
||||
implementation(libs.material)
|
||||
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
||||
|
||||
@@ -2,14 +2,16 @@ package com.example.architecture
|
||||
|
||||
import android.app.Application
|
||||
import com.example.architecture.core.data.di.coreDataModule
|
||||
import com.example.architecture.feature.characters.data.di.charactersDataModule
|
||||
import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Single Koin entry point. Feature modules append their own `*DataModule` / `*PresentationModule`
|
||||
* to the [modules] list — assembly happens only here, never inside feature modules.
|
||||
* Single Koin entry point. Every feature's `*DataModule` / `*PresentationModule` is assembled here,
|
||||
* never inside feature modules.
|
||||
*/
|
||||
class ArchitectureApp : Application() {
|
||||
override fun onCreate() {
|
||||
@@ -24,7 +26,11 @@ class ArchitectureApp : Application() {
|
||||
androidLogger()
|
||||
androidContext(this@ArchitectureApp)
|
||||
modules(
|
||||
// core
|
||||
coreDataModule,
|
||||
// characters feature
|
||||
charactersDataModule,
|
||||
charactersPresentationModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,31 +4,26 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.architecture.core.design.system.component.AppScaffold
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.architecture.core.design.system.theme.AppTheme
|
||||
import com.example.architecture.feature.characters.presentation.compose.CharacterListRoute
|
||||
import com.example.architecture.feature.characters.presentation.compose.charactersGraph
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
// Compose themes via AppTheme; the navigation host lands in a later milestone.
|
||||
AppTheme {
|
||||
AppScaffold { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = "Android Architecture Showcase")
|
||||
}
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = CharacterListRoute,
|
||||
) {
|
||||
charactersGraph(
|
||||
onCharacterClick = { /* Detail navigation is wired in the next milestone. */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ android {
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(libs.timber)
|
||||
// `api`: the public inline HttpClient.get/post/delete helpers are inlined into consumer modules,
|
||||
// so those modules need the Ktor request/response types on their compile classpath.
|
||||
api(libs.ktor.client.core)
|
||||
}
|
||||
|
||||
7
core/data/src/main/AndroidManifest.xml
Normal file
7
core/data/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
</manifest>
|
||||
@@ -65,21 +65,31 @@ suspend inline fun <reified T> safeCall(
|
||||
return try {
|
||||
responseToResult(execute())
|
||||
} catch (e: UnresolvedAddressException) {
|
||||
Timber.tag("HttpClient").e(e, "No internet (unresolved address)")
|
||||
logNetworkError(e, "No internet (unresolved address)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: UnknownHostException) {
|
||||
Timber.tag("HttpClient").e(e, "No internet (unknown host)")
|
||||
logNetworkError(e, "No internet (unknown host)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: SerializationException) {
|
||||
Timber.tag("HttpClient").e(e, "Serialization failure")
|
||||
logNetworkError(e, "Serialization failure")
|
||||
Result.Error(DataError.Network.SERIALIZATION)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Timber.tag("HttpClient").e(e, "Unknown network failure")
|
||||
logNetworkError(e, "Unknown network failure")
|
||||
Result.Error(DataError.Network.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
||||
* across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because
|
||||
* this function's body is not inlined into the caller.
|
||||
*/
|
||||
@PublishedApi
|
||||
internal fun logNetworkError(throwable: Throwable, message: String) {
|
||||
Timber.tag("HttpClient").e(throwable, message)
|
||||
}
|
||||
|
||||
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
|
||||
suspend inline fun <reified T> responseToResult(
|
||||
response: HttpResponse,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.architecture.feature.characters.data
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.map
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||
*/
|
||||
internal class NetworkCharacterRepository(
|
||||
private val dataSource: KtorCharacterDataSource,
|
||||
) : CharacterRepository {
|
||||
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||
dataSource.getCharacters(page).map { it.toDomain() }
|
||||
|
||||
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.architecture.feature.characters.data.datasource
|
||||
|
||||
import com.example.architecture.core.data.network.get
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
/**
|
||||
* Remote data source for characters. Returns raw DTOs (no mapping here — the repository maps via
|
||||
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||
*/
|
||||
internal class KtorCharacterDataSource(
|
||||
private val httpClient: HttpClient,
|
||||
) {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||
|
||||
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||
httpClient.get(route = "/character/$id")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.architecture.feature.characters.data.di
|
||||
|
||||
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val charactersDataModule = module {
|
||||
singleOf(::KtorCharacterDataSource)
|
||||
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharacterDto(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: LocationRefDto,
|
||||
val location: LocationRefDto,
|
||||
val image: String,
|
||||
val episode: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocationRefDto(
|
||||
val name: String,
|
||||
val url: String,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharactersResponseDto(
|
||||
val info: PageInfoDto,
|
||||
val results: List<CharacterDto>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PageInfoDto(
|
||||
val count: Int,
|
||||
val pages: Int,
|
||||
val next: String?,
|
||||
val prev: String?,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.architecture.feature.characters.data.mappers
|
||||
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||
characters = results.map { it.toCharacter() },
|
||||
nextPage = info.next?.toPageNumber(),
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
imageUrl = image,
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
type = type,
|
||||
gender = gender,
|
||||
origin = origin.name,
|
||||
location = location.name,
|
||||
imageUrl = image,
|
||||
episodeCount = episode.size,
|
||||
)
|
||||
|
||||
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||
"alive" -> CharacterStatus.ALIVE
|
||||
"dead" -> CharacterStatus.DEAD
|
||||
else -> CharacterStatus.UNKNOWN
|
||||
}
|
||||
|
||||
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||
private fun String.toPageNumber(): Int? =
|
||||
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.architecture.feature.characters.domain
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Contract for the characters data layer. Lives in domain so presentation never depends on data.
|
||||
* Returns the [DataError] supertype because an implementation may merge sources (e.g. an
|
||||
* offline-first repository combining network + local).
|
||||
*/
|
||||
interface CharacterRepository {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersPage, DataError>
|
||||
|
||||
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** A character as shown in the list. */
|
||||
data class Character(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val imageUrl: String,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Full character profile shown on the detail screen. */
|
||||
data class CharacterDetails(
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Life status of a character. Mapped from the API's string in the data layer. */
|
||||
enum class CharacterStatus {
|
||||
ALIVE,
|
||||
DEAD,
|
||||
UNKNOWN,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** One page of characters plus the next page index ([nextPage] is null when there are no more). */
|
||||
data class CharactersPage(
|
||||
val characters: List<Character>,
|
||||
val nextPage: Int?,
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.android.feature)
|
||||
// For @Serializable type-safe navigation routes.
|
||||
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation.compose
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Type-safe route for the characters list screen. */
|
||||
@Serializable
|
||||
data object CharacterListRoute
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun NavGraphBuilder.charactersGraph(
|
||||
onCharacterClick: (Int) -> Unit,
|
||||
) {
|
||||
composable<CharacterListRoute> {
|
||||
CharacterListRoot(onCharacterClick = onCharacterClick)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -17,4 +17,8 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
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).
|
||||
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
|
||||
api(libs.kotlinx.collections.immutable)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterListAction {
|
||||
data class OnCharacterClick(val characterId: Int) : CharacterListAction
|
||||
data object OnRetry : CharacterListAction
|
||||
data object OnLoadNextPage : CharacterListAction
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
|
||||
sealed interface CharacterListEvent {
|
||||
data class NavigateToDetail(val characterId: Int) : CharacterListEvent
|
||||
data class ShowSnackbar(val message: UiText) : CharacterListEvent
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
|
||||
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
|
||||
* Compose already treats as stable — so this module needs no Compose dependency. Navigation and
|
||||
* snackbars are one-time Events, never state.
|
||||
*/
|
||||
data class CharacterListState(
|
||||
val characters: ImmutableList<CharacterUi> = persistentListOf(),
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingNextPage: Boolean = false,
|
||||
val currentPage: Int = 1,
|
||||
val endReached: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
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.Result
|
||||
import com.example.architecture.core.domain.onFailure
|
||||
import com.example.architecture.core.domain.onSuccess
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.core.presentation.toUiText
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
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 characters list. Shared by BOTH the Compose and the Views
|
||||
* renderers. Updates [CharacterListState] only via `.update`, emits one-time [CharacterListEvent]s
|
||||
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
|
||||
*/
|
||||
class CharacterListViewModel(
|
||||
private val characterRepository: CharacterRepository,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CharacterListState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<CharacterListEvent>()
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
init {
|
||||
// After process death, rebuild the list up to the highest page that had been loaded.
|
||||
val restoredPage: Int = savedStateHandle[KEY_PAGE] ?: 1
|
||||
restore(restoredPage)
|
||||
}
|
||||
|
||||
fun onAction(action: CharacterListAction) {
|
||||
when (action) {
|
||||
is CharacterListAction.OnCharacterClick -> viewModelScope.launch {
|
||||
_events.send(CharacterListEvent.NavigateToDetail(action.characterId))
|
||||
}
|
||||
CharacterListAction.OnRetry -> retry()
|
||||
CharacterListAction.OnLoadNextPage -> loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restore(targetPage: Int) {
|
||||
// Flip the flag synchronously so a guard reading state sees it immediately.
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val accumulated = mutableListOf<CharacterUi>()
|
||||
var lastLoadedPage = 0
|
||||
var endReached = false
|
||||
var error: UiText? = null
|
||||
|
||||
var page = 1
|
||||
while (page <= targetPage) {
|
||||
when (val result = characterRepository.getCharacters(page)) {
|
||||
is Result.Success -> {
|
||||
accumulated += result.data.characters.map { it.toCharacterUi() }
|
||||
lastLoadedPage = page
|
||||
endReached = result.data.nextPage == null
|
||||
if (endReached) break
|
||||
}
|
||||
is Result.Error -> {
|
||||
error = result.error.toUiText()
|
||||
break
|
||||
}
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
// Always surface a failure — even a partial one where earlier pages loaded.
|
||||
if (error != null) {
|
||||
_events.send(CharacterListEvent.ShowSnackbar(error))
|
||||
}
|
||||
|
||||
if (accumulated.isEmpty()) {
|
||||
// Nothing loaded → full-screen error (or an empty list if the API simply had none).
|
||||
_state.update { it.copy(isLoading = false, error = error) }
|
||||
} else {
|
||||
val loadedPage = lastLoadedPage.coerceAtLeast(1)
|
||||
_state.update {
|
||||
it.copy(
|
||||
characters = accumulated.toImmutableList(),
|
||||
isLoading = false,
|
||||
currentPage = loadedPage,
|
||||
endReached = endReached,
|
||||
// The list is shown; the snackbar already surfaced any partial failure.
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
savedStateHandle[KEY_PAGE] = loadedPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
val current = _state.value
|
||||
if (current.isLoading || current.isLoadingNextPage || current.endReached) return
|
||||
loadPage(page = current.currentPage + 1)
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
val current = _state.value
|
||||
if (current.characters.isEmpty()) {
|
||||
restore(savedStateHandle[KEY_PAGE] ?: 1)
|
||||
} else {
|
||||
loadPage(page = current.currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPage(page: Int) {
|
||||
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
|
||||
// guarded out before its coroutine starts — otherwise the same page loads twice and items
|
||||
// get appended twice.
|
||||
_state.update { it.copy(isLoadingNextPage = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
characterRepository.getCharacters(page)
|
||||
.onSuccess { pageData ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
characters = (state.characters + pageData.characters.map { it.toCharacterUi() })
|
||||
.toImmutableList(),
|
||||
isLoadingNextPage = false,
|
||||
currentPage = page,
|
||||
endReached = pageData.nextPage == null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
savedStateHandle[KEY_PAGE] = page
|
||||
}
|
||||
.onFailure { failure ->
|
||||
val message = failure.toUiText()
|
||||
_state.update { it.copy(isLoadingNextPage = false, error = message) }
|
||||
_events.send(CharacterListEvent.ShowSnackbar(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_PAGE = "currentPage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.architecture.feature.characters.presentation.di
|
||||
|
||||
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. */
|
||||
val charactersPresentationModule = module {
|
||||
viewModelOf(::CharacterListViewModel)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation.model
|
||||
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/** Presentation model for a character list item — decouples the UI from the domain [Character]. */
|
||||
data class CharacterUi(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val species: String,
|
||||
val imageUrl: String,
|
||||
val status: CharacterStatus,
|
||||
)
|
||||
|
||||
fun Character.toCharacterUi(): CharacterUi = CharacterUi(
|
||||
id = id,
|
||||
name = name,
|
||||
species = species,
|
||||
imageUrl = imageUrl,
|
||||
status = status,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ composeBom = "2026.03.01"
|
||||
# Async / serialization
|
||||
coroutines = "1.10.2"
|
||||
kotlinxSerialization = "1.8.1"
|
||||
kotlinxCollectionsImmutable = "0.3.8"
|
||||
|
||||
# DI
|
||||
koin = "4.1.0"
|
||||
@@ -87,6 +88,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
||||
|
||||
# --- Koin (versions via BOM) ---
|
||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
||||
|
||||
Reference in New Issue
Block a user