Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
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,
// so it has no data/domain modules.
android {
namespace = "com.example.architecture.feature.about.presentation"
}
dependencies {
implementation(project(":core:presentation"))
implementation(project(":core:design-system"))
}

View File

@@ -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<AboutRoute> {
AboutRoot(onNavigateBack = onNavigateBack)
}
}

View File

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

View File

@@ -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<String> = emptyList(),
val mvvmNote: String = "",
val showMvvmNote: Boolean = false,
val links: List<AboutLink> = emptyList(),
)

View File

@@ -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<AboutState> = _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) }
}
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<resources>
<string name="about_title">About</string>
<string name="about_architecture_header">Architecture highlights</string>
<string name="about_mvvm_header">Why is this screen MVVM?</string>
<string name="about_mvvm_hint">Tap to see how this MVVM screen differs from the app\'s MVI screens.</string>
<string name="about_links_header">Links</string>
<string name="cd_back">Back</string>
</resources>

View File

@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.kotlinx.serialization)
alias(libs.plugins.architecture.android.unit.test)
}
android {
namespace = "com.example.architecture.feature.characters.data"
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":feature:characters:domain"))
// Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test.
testImplementation(libs.ktor.client.mock)
testImplementation(libs.ktor.client.content.negotiation)
testImplementation(libs.ktor.serialization.kotlinx.json)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?,
)

View File

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

View File

@@ -0,0 +1,162 @@
package com.example.architecture.feature.characters.data
import assertk.assertThat
import assertk.assertions.endsWith
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import com.example.architecture.core.data.network.HttpClientFactory
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) - so the
* full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping →
* domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a
* malformed-body → SERIALIZATION case.
*/
class NetworkCharacterRepositoryTest {
private fun repository(
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
): NetworkCharacterRepository {
val engine = MockEngine { request -> handler(request) }
val httpClient = HttpClientFactory.create(engine)
return NetworkCharacterRepository(KtorCharacterDataSource(httpClient))
}
private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json")
@Test
fun `getCharacters maps a successful response to a domain page`() = runTest {
var requestedPath: String? = null
var requestedPage: String? = null
val repository = repository { request ->
requestedPath = request.url.encodedPath
requestedPage = request.url.parameters["page"]
respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacters(page = 3)
// Request construction: correct endpoint and the page forwarded as a query param.
assertThat(requestedPath).isNotNull().endsWith("/character")
assertThat(requestedPage).isEqualTo("3")
assertThat(result).isInstanceOf(Result.Success::class)
val page = (result as Result.Success).data
assertThat(page.characters.size).isEqualTo(2)
assertThat(page.characters.first().name).isEqualTo("Rick Sanchez")
// `next` URL ".../character?page=2" is parsed to a page number.
assertThat(page.nextPage).isEqualTo(2)
}
@Test
fun `getCharacters maps 404 to NOT_FOUND`() = runTest {
val repository = repository {
respond(content = "", status = HttpStatusCode.NotFound)
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND))
}
@Test
fun `getCharacters maps 500 to SERVER_ERROR`() = runTest {
val repository = repository {
respond(content = "", status = HttpStatusCode.InternalServerError)
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR))
}
@Test
fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest {
val repository = repository {
respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION))
}
@Test
fun `getCharacterDetails maps a successful response to domain details`() = runTest {
var requestedPath: String? = null
val repository = repository { request ->
requestedPath = request.url.encodedPath
respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacterDetails(id = 1)
// Request construction: the id is placed in the path.
assertThat(requestedPath).isNotNull().endsWith("/character/1")
assertThat(result).isInstanceOf(Result.Success::class)
val details = (result as Result.Success).data
assertThat(details.name).isEqualTo("Rick Sanchez")
assertThat(details.origin).isEqualTo("Earth (C-137)")
assertThat(details.episodeCount).isEqualTo(3)
}
private companion object {
val CHARACTER_JSON = """
{
"id": 1,
"name": "Rick Sanchez",
"status": "Alive",
"species": "Human",
"type": "",
"gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/1.png",
"episode": ["e1", "e2", "e3"]
}
""".trimIndent()
val CHARACTERS_PAGE_JSON = """
{
"info": {
"count": 2,
"pages": 1,
"next": "https://rickandmortyapi.com/api/character?page=2",
"prev": null
},
"results": [
{
"id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human",
"type": "", "gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/1.png", "episode": ["e1", "e2"]
},
{
"id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human",
"type": "", "gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/2.png", "episode": ["e1"]
}
]
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.architecture.domain.module)
}
dependencies {
implementation(project(":core:domain"))
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?,
)

View File

@@ -0,0 +1,27 @@
package com.example.architecture.feature.characters.domain.usecase
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.CharactersPage
/**
* Loads one page of characters.
*
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
* logic that does NOT belong in the ViewModel - non-trivial rules, or *composition* of several
* repositories/sources into one domain operation. When the ViewModel would merely forward a single
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
*
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) - or
* delete it and let the ViewModel call the repository.
*/
class GetCharactersPageUseCase(
private val characterRepository: CharacterRepository,
) {
suspend operator fun invoke(page: Int): Result<CharactersPage, DataError> =
characterRepository.getCharacters(page)
}

View File

@@ -0,0 +1,66 @@
package com.example.architecture.feature.characters.domain.usecase
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
* the repository and return its result verbatim - success and error alike. Pure JVM test on the
* JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
*/
class GetCharactersPageUseCaseTest {
private val repository = mockk<CharacterRepository>()
private val useCase = GetCharactersPageUseCase(repository)
@Test
fun `returns the repository page on success`() = runTest {
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
coEvery { repository.getCharacters(1) } returns Result.Success(page)
val result = useCase(page = 1)
assertThat(result).isEqualTo(Result.Success(page))
}
@Test
fun `propagates the repository error`() = runTest {
coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
val result = useCase(page = 1)
assertThat(result).isInstanceOf(Result.Error::class)
assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR)
}
@Test
fun `forwards the requested page number`() = runTest {
coEvery { repository.getCharacters(any()) } returns
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
useCase(page = 7)
coVerify(exactly = 1) { repository.getCharacters(7) }
}
private fun domainCharacter(id: Int) = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
}

View File

@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.architecture.android.feature)
// For @Serializable type-safe navigation routes.
alias(libs.plugins.architecture.kotlinx.serialization)
}
android {
namespace = "com.example.architecture.feature.characters.presentation.compose"
}
dependencies {
implementation(project(":core:presentation"))
implementation(project(":core:design-system"))
implementation(project(":feature:characters:domain"))
implementation(project(":feature:characters:presentation"))
// Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to
// androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in.
androidTestImplementation(libs.bundles.compose.ui.test)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,81 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import org.junit.Assert.assertTrue
/**
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
* the interaction vocabulary; the test owns the assertions' intent - keeping tests readable and
* resilient to UI structure changes.
*/
class CharacterListRobot(
private val composeRule: ComposeContentTestRule,
private val context: Context,
) {
private val recordedActions = mutableListOf<CharacterListAction>()
fun setContent(state: CharacterListState): CharacterListRobot {
composeRule.setContent {
AppTheme {
CharacterListScreen(
state = state,
onAction = { recordedActions += it },
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
return this
}
fun assertCharacterShown(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).assertIsDisplayed()
return this
}
fun assertEmptyStateShown(): CharacterListRobot {
composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed()
return this
}
fun assertErrorShown(message: String): CharacterListRobot {
composeRule.onNodeWithText(message).assertIsDisplayed()
return this
}
fun assertRetryShown(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).assertIsDisplayed()
return this
}
fun clickCharacter(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).performClick()
return this
}
fun clickRetry(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).performClick()
return this
}
fun assertActionRecorded(action: CharacterListAction): CharacterListRobot {
assertTrue(
"Expected $action to be recorded, but got $recordedActions",
recordedActions.contains(action),
)
return this
}
// The retry label lives in the design-system module; reference its R directly (non-transitive R).
private val retryLabel: String
get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry)
}

View File

@@ -0,0 +1,77 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a
* device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the
* empty + error states, and that user gestures fire the right MVI [CharacterListAction]s.
*/
@RunWith(AndroidJUnit4::class)
class CharacterListScreenTest {
@get:Rule
val composeRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private fun robot() = CharacterListRobot(composeRule, context)
private val loadedState = CharacterListState(
characters = persistentListOf(
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
),
)
@Test
fun rendersCharacterItems() {
robot()
.setContent(loadedState)
.assertCharacterShown("Rick Sanchez")
.assertCharacterShown("Morty Smith")
}
@Test
fun showsEmptyState() {
robot()
.setContent(CharacterListState())
.assertEmptyStateShown()
}
@Test
fun showsErrorStateWithRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.assertErrorShown("Boom")
.assertRetryShown()
}
@Test
fun tappingAnItemFiresOnCharacterClick() {
robot()
.setContent(loadedState)
.clickCharacter("Rick Sanchez")
.assertActionRecorded(CharacterListAction.OnCharacterClick(1))
}
@Test
fun tappingRetryFiresOnRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.clickRetry()
.assertActionRecorded(CharacterListAction.OnRetry)
}
}

View File

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

View File

@@ -0,0 +1,325 @@
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.PaddingValues
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.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
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.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.
*
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] 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,
onOpenErrorDemo: () -> 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,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
snackbarHostState = snackbarHostState,
)
}
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterListScreen(
state: CharacterListState,
onAction: (CharacterListAction) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.characters_title)) },
actions = {
CharacterListOverflowMenu(
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
)
},
)
},
) { 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 CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> 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_error_demo)) },
onClick = {
expanded = false
onOpenErrorDemo()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_about)) },
onClick = {
expanded = false
onOpenAbout()
},
)
}
}
@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 = 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 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 = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
@Preview
@Composable
private fun CharacterListScreenErrorPreview() {
AppTheme {
CharacterListScreen(
state = CharacterListState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"No internet connection.",
),
),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}

View File

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

View File

@@ -0,0 +1,49 @@
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
/** Type-safe route for the characters list screen. */
@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)
/** Type-safe route for the error-handling demo screen. */
@Serializable
data object ErrorDemoRoute
/**
* The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so
* they are 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(
navController: NavController,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
) {
composable<CharacterListRoute> {
CharacterListRoot(
onCharacterClick = { characterId ->
navController.navigate(CharacterDetailRoute(characterId))
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
)
}
composable<CharacterDetailRoute> {
// 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() })
}
composable<ErrorDemoRoute> {
ErrorDemoRoot(onNavigateBack = { navController.popBackStack() })
}
}

View File

@@ -0,0 +1,156 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.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.theme.AppTheme
import com.example.architecture.core.presentation.ObserveAsEvents
import com.example.architecture.core.presentation.asString
import com.example.architecture.feature.characters.presentation.ErrorDemoAction
import com.example.architecture.feature.characters.presentation.ErrorDemoEvent
import com.example.architecture.feature.characters.presentation.ErrorDemoState
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import com.example.architecture.feature.characters.presentation.ErrorScenario
import org.koin.androidx.compose.koinViewModel
/**
* Root: owns the demo ViewModel (Koin) and forwards the one-time NavigateBack event up the stack.
*/
@Composable
fun ErrorDemoRoot(
onNavigateBack: () -> Unit,
viewModel: ErrorDemoViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
ErrorDemoEvent.NavigateBack -> onNavigateBack()
}
}
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDemoScreen(
state: ErrorDemoState,
onAction: (ErrorDemoAction) -> Unit,
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.error_demo_title)) },
navigationIcon = {
IconButton(onClick = { onAction(ErrorDemoAction.OnBackClick) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.error_demo_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NO_INTERNET)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_no_internet)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NOT_FOUND)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_not_found)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.SERVER_ERROR)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_server)) }
Button(
onClick = { onAction(ErrorDemoAction.OnLoadSuccess) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_load_success)) }
// Result area: loading → mapped error (with retry) → success → idle hint.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val error = state.error
when {
state.isLoading -> LoadingIndicator()
error != null -> ErrorState(
message = error.asString(),
onRetry = { onAction(ErrorDemoAction.OnRetry) },
)
state.loaded -> Text(
text = stringResource(R.string.error_demo_success),
style = MaterialTheme.typography.titleMedium,
)
else -> Text(
text = stringResource(R.string.error_demo_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Preview
@Composable
private fun ErrorDemoScreenIdlePreview() {
AppTheme { ErrorDemoScreen(state = ErrorDemoState(), onAction = {}) }
}
@Preview
@Composable
private fun ErrorDemoScreenErrorPreview() {
AppTheme {
ErrorDemoScreen(
state = ErrorDemoState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"No internet connection. Check your network and try again.",
),
),
onAction = {},
)
}
}

View File

@@ -0,0 +1,34 @@
<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>
<!-- Overflow menu -->
<string name="cd_more_options">More options</string>
<string name="menu_about">About</string>
<string name="menu_open_as_views">Open as Views</string>
<string name="menu_error_demo">Error handling demo</string>
<!-- Error-handling demo screen -->
<string name="error_demo_title">Error handling demo</string>
<string name="error_demo_intro">Force a network failure to watch it flow through the pipeline: DataError.Network → toUiText() → the shared ErrorState. Retry re-issues the same request; a successful load clears the error.</string>
<string name="error_demo_force_no_internet">Force: No internet</string>
<string name="error_demo_force_not_found">Force: Not found</string>
<string name="error_demo_force_server">Force: Server error</string>
<string name="error_demo_load_success">Load (success)</string>
<string name="error_demo_success">Loaded successfully ✓</string>
<string name="error_demo_hint">Pick an action above to see the result here.</string>
<!-- Detail screen -->
<string name="character_detail_title">Character</string>
<string name="cd_back">Back</string>
<string name="cd_character_image">Image of %1$s</string>
<string name="detail_type">Type</string>
<string name="detail_gender">Gender</string>
<string name="detail_origin">Origin</string>
<string name="detail_location">Location</string>
<string name="detail_episodes">Episodes</string>
</resources>

View File

@@ -0,0 +1,15 @@
plugins {
alias(libs.plugins.architecture.android.feature.views)
}
// Classic Views renderer (Fragment + ViewBinding + RecyclerView) driving the SAME ViewModel from
// :feature:characters:presentation. ViewBinding ON, Compose OFF.
android {
namespace = "com.example.architecture.feature.characters.presentation.views"
}
dependencies {
implementation(project(":core:presentation"))
implementation(project(":feature:characters:domain"))
implementation(project(":feature:characters:presentation"))
}

View File

@@ -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<CharacterUi, CharacterListAdapter.CharacterViewHolder>(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<CharacterUi>() {
override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem == newItem
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Solid white oval, tinted per status at bind time via setBackgroundTintList. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/white" />
</shape>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationContentDescription="@string/cd_back"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/characters_views_title" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/errorContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/errorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<Button
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/characters_views_retry" />
</LinearLayout>
<ProgressBar
android:id="@+id/nextPageProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="16dp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:id="@+id/statusDot"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_status_dot" />
<TextView
android:id="@+id/statusSpecies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<resources>
<string name="characters_views_title">Characters (Views)</string>
<string name="characters_views_retry">Retry</string>
<string name="cd_back">Back</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="characters_views_status_alive">Alive</string>
<string name="characters_views_status_dead">Dead</string>
<string name="characters_views_status_unknown">Unknown</string>
</resources>

View File

@@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.android.unit.test)
}
// UI-agnostic presentation: the MVI ViewModel + State/Action/Event live here and are shared by
// BOTH the Compose and the Views renderers. No Compose, no Views dependencies on purpose.
android {
namespace = "com.example.architecture.feature.characters.presentation"
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:characters:domain"))
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)
}

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailAction {
data object OnRetry : CharacterDetailAction
data object OnBackClick : CharacterDetailAction
}

View File

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

View File

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

View File

@@ -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<CharacterDetailRoute>()` -
* 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<Int>(KEY_CHARACTER_ID)) {
"CharacterDetailRoute.characterId missing from SavedStateHandle"
}
private val _state = MutableStateFlow(CharacterDetailState())
val state = _state.asStateFlow()
private val _events = Channel<CharacterDetailEvent>()
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"
}
}

View File

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

View File

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

View File

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

View File

@@ -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.usecase.GetCharactersPageUseCase
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 getCharactersPage: GetCharactersPageUseCase,
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 = getCharactersPage(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 {
getCharactersPage(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"
}
}

View File

@@ -0,0 +1,14 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoAction {
/** Force a load that fails with the given [ErrorScenario]. */
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
/** Force a load that succeeds - clears any current error. */
data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */
data object OnRetry : ErrorDemoAction
data object OnBackClick : ErrorDemoAction
}

View File

@@ -0,0 +1,5 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoEvent {
data object NavigateBack : ErrorDemoEvent
}

View File

@@ -0,0 +1,25 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
/**
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` - exactly what the real
* screens hold - so the renderer resolves and shows it the same way.
*/
data class ErrorDemoState(
val isLoading: Boolean = false,
val loaded: Boolean = false,
val error: UiText? = null,
)
/**
* The failure the user asks the demo to reproduce. A presentation-local choice (not a `DataError`)
* so the renderer stays free of domain error types; the ViewModel maps it to the real
* `DataError.Network` case.
*/
enum class ErrorScenario {
NO_INTERNET,
NOT_FOUND,
SERVER_ERROR,
}

View File

@@ -0,0 +1,87 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.architecture.core.domain.DataError
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.toUiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
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 **error-handling demo** - a runnable walk-through of the whole
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
* through the *same* steps a genuine network call uses:
*
* ```
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
* ```
*
* The outcome is *simulated* (no real request) only so every case - including NO_INTERNET, which you
* can't reliably trigger on demand - is reachable deterministically. [OnRetry] re-issues the last
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
* success).
*/
class ErrorDemoViewModel : ViewModel() {
private val _state = MutableStateFlow(ErrorDemoState())
val state = _state.asStateFlow()
private val _events = Channel<ErrorDemoEvent>()
val events = _events.receiveAsFlow()
// Remembered so OnRetry re-issues exactly what was last attempted.
private var lastAttempt: Attempt = Attempt.Success
fun onAction(action: ErrorDemoAction) {
when (action) {
is ErrorDemoAction.OnForceError -> load(Attempt.Fail(action.scenario))
ErrorDemoAction.OnLoadSuccess -> load(Attempt.Success)
ErrorDemoAction.OnRetry -> load(lastAttempt)
ErrorDemoAction.OnBackClick -> viewModelScope.launch {
_events.send(ErrorDemoEvent.NavigateBack)
}
}
}
private fun load(attempt: Attempt) {
lastAttempt = attempt
_state.update { it.copy(isLoading = true, error = null, loaded = false) }
viewModelScope.launch {
delay(LOAD_DELAY_MS) // pretend a request is in flight, so the loading state is visible
simulate(attempt)
.onSuccess { _state.update { it.copy(isLoading = false, loaded = true, error = null) } }
.onFailure { dataError ->
// The crux of the demo: a DataError becomes user-facing UiText right here.
_state.update { it.copy(isLoading = false, error = dataError.toUiText()) }
}
}
}
private fun simulate(attempt: Attempt): Result<Unit, DataError.Network> = when (attempt) {
Attempt.Success -> Result.Success(Unit)
is Attempt.Fail -> Result.Error(attempt.scenario.toDataError())
}
private sealed interface Attempt {
data object Success : Attempt
data class Fail(val scenario: ErrorScenario) : Attempt
}
private fun ErrorScenario.toDataError(): DataError.Network = when (this) {
ErrorScenario.NO_INTERNET -> DataError.Network.NO_INTERNET
ErrorScenario.NOT_FOUND -> DataError.Network.NOT_FOUND
ErrorScenario.SERVER_ERROR -> DataError.Network.SERVER_ERROR
}
private companion object {
const val LOAD_DELAY_MS = 400L
}
}

View File

@@ -0,0 +1,19 @@
package com.example.architecture.feature.characters.presentation.di
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module {
// Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule.
factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}

View File

@@ -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 = ""

View File

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

View File

@@ -0,0 +1,120 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
* by type-safe navigation), which is constructed directly here - proving the VM needs no navigation
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
* assertions use AssertK; the back event is observed with Turbine.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<CharacterRepository>(relaxed = true)
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(characterId: Int = 1) =
CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository)
@Test
fun `loads details on init`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
assertThat(state.details?.name).isEqualTo("Character 1")
}
@Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNotNull()
assertThat(state.details).isNull()
assertThat(state.isLoading).isFalse()
}
@Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull()
// Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
}
@Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
viewModel.events.test {
viewModel.onAction(CharacterDetailAction.OnBackClick)
advanceUntilIdle()
assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
// The constructor throws before the (relaxed) repository is ever touched.
assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository)
}
}
}

View File

@@ -0,0 +1,28 @@
package com.example.architecture.feature.characters.presentation
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
/** Minimal list-item domain fixture for presentation tests. */
fun character(id: Int): Character = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
/** Minimal detail domain fixture for presentation tests. */
fun characterDetails(id: Int): CharacterDetails = CharacterDetails(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
type = "Genetic experiment",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "https://example.com/$id.png",
episodeCount = 10,
)

View File

@@ -0,0 +1,243 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.CharactersPage
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Unit tests for [CharacterListViewModel] - driven entirely through its public MVI surface
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
*
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
* which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any
* coroutine runs. The collaborator is a MockK mock of [CharacterRepository] (the real
* [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
* `state`/`events` are observed with Turbine; assertions use AssertK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterListViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<CharacterRepository>()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) =
CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle)
@Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
val viewModel = viewModel()
viewModel.state.test {
// restore() flips isLoading synchronously during construction, before the coroutine runs.
assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true))
advanceUntilIdle()
val loaded = awaitItem()
assertThat(loaded).prop(CharacterListState::characters).hasSize(2)
assertThat(loaded).prop(CharacterListState::isLoading).isFalse()
assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1)
assertThat(loaded).prop(CharacterListState::endReached).isFalse()
assertThat(loaded).prop(CharacterListState::error).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
cancelAndIgnoreRemainingEvents()
}
advanceUntilIdle()
assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0)
}
@Test
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null)) // last page
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // → page 2, end reached
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // guarded by endReached → no request
// Page 2 was fetched exactly once and no page 3 was ever requested (a page-3 fetch would also
// blow up the strict mock as an unstubbed call).
coVerify(exactly = 1) { repository.getCharacters(2) }
coVerify(exactly = 0) { repository.getCharacters(3) }
}
@Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
// Both fire before any launched coroutine runs; the second sees the synchronously-set
// isLoadingNextPage flag and is guarded out.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
coVerify(exactly = 1) { repository.getCharacters(2) }
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
@Test
fun `ignores a next-page request while the initial load is in flight`() =
runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
val viewModel = viewModel()
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
// OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
// Only the single initial load ran - the guarded next-page request never fired.
coVerify(exactly = 1) { repository.getCharacters(1) }
coVerify(exactly = 0) { repository.getCharacters(2) }
}
@Test
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
// The initial-load failure surfaces as a snackbar; consuming it is also how the
// rendezvous-Channel send in restore() completes so state can settle.
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
// Empty branch of retry(): the repository recovers (the later, more specific stub for
// page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
}
@Test
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test {
// Page 2 fails on the first attempt → list keeps page 1, shows an error.
coEvery { repository.getCharacters(2) } returns Result.Error(DataError.Network.NOT_FOUND)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1)
// Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and
// appends it (currentPage stayed 1 because loadPage only advances on success).
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null))
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
// Page 2 was requested twice: the failed first load and the successful retry.
coVerify(exactly = 2) { repository.getCharacters(2) }
}
@Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
// Navigation/SavedStateHandle persisted the last loaded page across process death.
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
val viewModel = viewModel(savedStateHandle)
advanceUntilIdle()
// Both pages are rebuilt (1 then 2), and currentPage is restored.
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
coVerifyOrder {
repository.getCharacters(1)
repository.getCharacters(2)
}
}
}