16
feature/about/presentation/build.gradle.kts
Normal file
16
feature/about/presentation/build.gradle.kts
Normal 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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
21
feature/characters/data/build.gradle.kts
Normal file
21
feature/characters/data/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.architecture.feature.characters.data
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.map
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||
*/
|
||||
internal class NetworkCharacterRepository(
|
||||
private val dataSource: KtorCharacterDataSource,
|
||||
) : CharacterRepository {
|
||||
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||
dataSource.getCharacters(page).map { it.toDomain() }
|
||||
|
||||
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.architecture.feature.characters.data.datasource
|
||||
|
||||
import com.example.architecture.core.data.network.get
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
/**
|
||||
* Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
|
||||
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||
*/
|
||||
internal class KtorCharacterDataSource(
|
||||
private val httpClient: HttpClient,
|
||||
) {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||
|
||||
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||
httpClient.get(route = "/character/$id")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.architecture.feature.characters.data.di
|
||||
|
||||
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val charactersDataModule = module {
|
||||
singleOf(::KtorCharacterDataSource)
|
||||
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharacterDto(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: LocationRefDto,
|
||||
val location: LocationRefDto,
|
||||
val image: String,
|
||||
val episode: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocationRefDto(
|
||||
val name: String,
|
||||
val url: String,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharactersResponseDto(
|
||||
val info: PageInfoDto,
|
||||
val results: List<CharacterDto>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PageInfoDto(
|
||||
val count: Int,
|
||||
val pages: Int,
|
||||
val next: String?,
|
||||
val prev: String?,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.architecture.feature.characters.data.mappers
|
||||
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||
characters = results.map { it.toCharacter() },
|
||||
nextPage = info.next?.toPageNumber(),
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
imageUrl = image,
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
type = type,
|
||||
gender = gender,
|
||||
origin = origin.name,
|
||||
location = location.name,
|
||||
imageUrl = image,
|
||||
episodeCount = episode.size,
|
||||
)
|
||||
|
||||
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||
"alive" -> CharacterStatus.ALIVE
|
||||
"dead" -> CharacterStatus.DEAD
|
||||
else -> CharacterStatus.UNKNOWN
|
||||
}
|
||||
|
||||
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||
private fun String.toPageNumber(): Int? =
|
||||
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
7
feature/characters/domain/build.gradle.kts
Normal file
7
feature/characters/domain/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.domain.module)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.architecture.feature.characters.domain
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Contract for the characters data layer. Lives in domain so presentation never depends on data.
|
||||
* Returns the [DataError] supertype because an implementation may merge sources (e.g. an
|
||||
* offline-first repository combining network + local).
|
||||
*/
|
||||
interface CharacterRepository {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersPage, DataError>
|
||||
|
||||
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** A character as shown in the list. */
|
||||
data class Character(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val imageUrl: String,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Full character profile shown on the detail screen. */
|
||||
data class CharacterDetails(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: String,
|
||||
val location: String,
|
||||
val imageUrl: String,
|
||||
val episodeCount: Int,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Life status of a character. Mapped from the API's string in the data layer. */
|
||||
enum class CharacterStatus {
|
||||
ALIVE,
|
||||
DEAD,
|
||||
UNKNOWN,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** One page of characters plus the next page index ([nextPage] is null when there are no more). */
|
||||
data class CharactersPage(
|
||||
val characters: List<Character>,
|
||||
val nextPage: Int?,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
21
feature/characters/presentation-compose/build.gradle.kts
Normal file
21
feature/characters/presentation-compose/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
15
feature/characters/presentation-views/build.gradle.kts
Normal file
15
feature/characters/presentation-views/build.gradle.kts
Normal 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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
25
feature/characters/presentation/build.gradle.kts
Normal file
25
feature/characters/presentation/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterDetailAction {
|
||||
data object OnRetry : CharacterDetailAction
|
||||
data object OnBackClick : CharacterDetailAction
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterListAction {
|
||||
data class OnCharacterClick(val characterId: Int) : CharacterListAction
|
||||
data object OnRetry : CharacterListAction
|
||||
data object OnLoadNextPage : CharacterListAction
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
|
||||
sealed interface CharacterListEvent {
|
||||
data class NavigateToDetail(val characterId: Int) : CharacterListEvent
|
||||
data class ShowSnackbar(val message: UiText) : CharacterListEvent
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
|
||||
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
|
||||
* Compose already treats as stable - so this module needs no Compose dependency. Navigation and
|
||||
* snackbars are one-time Events, never state.
|
||||
*/
|
||||
data class CharacterListState(
|
||||
val characters: ImmutableList<CharacterUi> = persistentListOf(),
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingNextPage: Boolean = false,
|
||||
val currentPage: Int = 1,
|
||||
val endReached: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.onFailure
|
||||
import com.example.architecture.core.domain.onSuccess
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.core.presentation.toUiText
|
||||
import com.example.architecture.feature.characters.domain.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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface ErrorDemoEvent {
|
||||
data object NavigateBack : ErrorDemoEvent
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = "—"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user