Merge pull request #2 from AdrianKuta/feat/flagship-mvi
Flagship MVI Feature (REDI-85…89)
This commit is contained in:
@@ -16,10 +16,16 @@ dependencies {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:design-system"))
|
implementation(project(":core:design-system"))
|
||||||
|
|
||||||
|
// Characters feature: data + presentation (Koin modules) + Compose renderer (nav graph).
|
||||||
|
implementation(project(":feature:characters:data"))
|
||||||
|
implementation(project(":feature:characters:presentation"))
|
||||||
|
implementation(project(":feature:characters:presentation-compose"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.bundles.lifecycle.compose)
|
implementation(libs.bundles.lifecycle.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
// Material Components — required for the Material3 XML Activity theme.
|
// Material Components — required for the Material3 XML Activity theme.
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ package com.example.architecture
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.architecture.core.data.di.coreDataModule
|
import com.example.architecture.core.data.di.coreDataModule
|
||||||
|
import com.example.architecture.feature.characters.data.di.charactersDataModule
|
||||||
|
import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Koin entry point. Feature modules append their own `*DataModule` / `*PresentationModule`
|
* Single Koin entry point. Every feature's `*DataModule` / `*PresentationModule` is assembled here,
|
||||||
* to the [modules] list — assembly happens only here, never inside feature modules.
|
* never inside feature modules.
|
||||||
*/
|
*/
|
||||||
class ArchitectureApp : Application() {
|
class ArchitectureApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -24,7 +26,11 @@ class ArchitectureApp : Application() {
|
|||||||
androidLogger()
|
androidLogger()
|
||||||
androidContext(this@ArchitectureApp)
|
androidContext(this@ArchitectureApp)
|
||||||
modules(
|
modules(
|
||||||
|
// core
|
||||||
coreDataModule,
|
coreDataModule,
|
||||||
|
// characters feature
|
||||||
|
charactersDataModule,
|
||||||
|
charactersPresentationModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,26 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.example.architecture.core.design.system.component.AppScaffold
|
|
||||||
import com.example.architecture.core.design.system.theme.AppTheme
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.feature.characters.presentation.compose.CharacterListRoute
|
||||||
|
import com.example.architecture.feature.characters.presentation.compose.charactersGraph
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
// Compose themes via AppTheme; the navigation host lands in a later milestone.
|
|
||||||
AppTheme {
|
AppTheme {
|
||||||
AppScaffold { innerPadding ->
|
val navController = rememberNavController()
|
||||||
Box(
|
NavHost(
|
||||||
modifier = Modifier
|
navController = navController,
|
||||||
.fillMaxSize()
|
startDestination = CharacterListRoute,
|
||||||
.padding(innerPadding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
Text(text = "Android Architecture Showcase")
|
charactersGraph(
|
||||||
}
|
onCharacterClick = { /* Detail navigation is wired in the next milestone. */ },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
// `api`: the public inline HttpClient.get/post/delete helpers are inlined into consumer modules,
|
||||||
|
// so those modules need the Ktor request/response types on their compile classpath.
|
||||||
|
api(libs.ktor.client.core)
|
||||||
}
|
}
|
||||||
|
|||||||
7
core/data/src/main/AndroidManifest.xml
Normal file
7
core/data/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -65,21 +65,31 @@ suspend inline fun <reified T> safeCall(
|
|||||||
return try {
|
return try {
|
||||||
responseToResult(execute())
|
responseToResult(execute())
|
||||||
} catch (e: UnresolvedAddressException) {
|
} catch (e: UnresolvedAddressException) {
|
||||||
Timber.tag("HttpClient").e(e, "No internet (unresolved address)")
|
logNetworkError(e, "No internet (unresolved address)")
|
||||||
Result.Error(DataError.Network.NO_INTERNET)
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
} catch (e: UnknownHostException) {
|
} catch (e: UnknownHostException) {
|
||||||
Timber.tag("HttpClient").e(e, "No internet (unknown host)")
|
logNetworkError(e, "No internet (unknown host)")
|
||||||
Result.Error(DataError.Network.NO_INTERNET)
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
} catch (e: SerializationException) {
|
} catch (e: SerializationException) {
|
||||||
Timber.tag("HttpClient").e(e, "Serialization failure")
|
logNetworkError(e, "Serialization failure")
|
||||||
Result.Error(DataError.Network.SERIALIZATION)
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
Timber.tag("HttpClient").e(e, "Unknown network failure")
|
logNetworkError(e, "Unknown network failure")
|
||||||
Result.Error(DataError.Network.UNKNOWN)
|
Result.Error(DataError.Network.UNKNOWN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
||||||
|
* across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because
|
||||||
|
* this function's body is not inlined into the caller.
|
||||||
|
*/
|
||||||
|
@PublishedApi
|
||||||
|
internal fun logNetworkError(throwable: Throwable, message: String) {
|
||||||
|
Timber.tag("HttpClient").e(throwable, message)
|
||||||
|
}
|
||||||
|
|
||||||
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
|
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
|
||||||
suspend inline fun <reified T> responseToResult(
|
suspend inline fun <reified T> responseToResult(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.architecture.feature.characters.data
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.core.domain.map
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||||
|
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||||
|
*/
|
||||||
|
internal class NetworkCharacterRepository(
|
||||||
|
private val dataSource: KtorCharacterDataSource,
|
||||||
|
) : CharacterRepository {
|
||||||
|
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||||
|
dataSource.getCharacters(page).map { it.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||||
|
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.datasource
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.network.get
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote data source for characters. Returns raw DTOs (no mapping here — the repository maps via
|
||||||
|
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||||
|
*/
|
||||||
|
internal class KtorCharacterDataSource(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
) {
|
||||||
|
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||||
|
|
||||||
|
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character/$id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import org.koin.core.module.dsl.bind
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val charactersDataModule = module {
|
||||||
|
singleOf(::KtorCharacterDataSource)
|
||||||
|
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharacterDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val species: String,
|
||||||
|
val type: String,
|
||||||
|
val gender: String,
|
||||||
|
val origin: LocationRefDto,
|
||||||
|
val location: LocationRefDto,
|
||||||
|
val image: String,
|
||||||
|
val episode: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocationRefDto(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharactersResponseDto(
|
||||||
|
val info: PageInfoDto,
|
||||||
|
val results: List<CharacterDto>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageInfoDto(
|
||||||
|
val count: Int,
|
||||||
|
val pages: Int,
|
||||||
|
val next: String?,
|
||||||
|
val prev: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.mappers
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||||
|
characters = results.map { it.toCharacter() },
|
||||||
|
nextPage = info.next?.toPageNumber(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
imageUrl = image,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
type = type,
|
||||||
|
gender = gender,
|
||||||
|
origin = origin.name,
|
||||||
|
location = location.name,
|
||||||
|
imageUrl = image,
|
||||||
|
episodeCount = episode.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||||
|
"alive" -> CharacterStatus.ALIVE
|
||||||
|
"dead" -> CharacterStatus.DEAD
|
||||||
|
else -> CharacterStatus.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||||
|
private fun String.toPageNumber(): Int? =
|
||||||
|
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for the characters data layer. Lives in domain so presentation never depends on data.
|
||||||
|
* Returns the [DataError] supertype because an implementation may merge sources (e.g. an
|
||||||
|
* offline-first repository combining network + local).
|
||||||
|
*/
|
||||||
|
interface CharacterRepository {
|
||||||
|
suspend fun getCharacters(page: Int): Result<CharactersPage, DataError>
|
||||||
|
|
||||||
|
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** A character as shown in the list. */
|
||||||
|
data class Character(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: CharacterStatus,
|
||||||
|
val species: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** Full character profile shown on the detail screen. */
|
||||||
|
data class CharacterDetails(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: CharacterStatus,
|
||||||
|
val species: String,
|
||||||
|
val type: String,
|
||||||
|
val gender: String,
|
||||||
|
val origin: String,
|
||||||
|
val location: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val episodeCount: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** Life status of a character. Mapped from the API's string in the data layer. */
|
||||||
|
enum class CharacterStatus {
|
||||||
|
ALIVE,
|
||||||
|
DEAD,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** One page of characters plus the next page index ([nextPage] is null when there are no more). */
|
||||||
|
data class CharactersPage(
|
||||||
|
val characters: List<Character>,
|
||||||
|
val nextPage: Int?,
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.architecture.android.feature)
|
alias(libs.plugins.architecture.android.feature)
|
||||||
|
// For @Serializable type-safe navigation routes.
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.architecture.core.design.system.component.AppCard
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.component.ErrorState
|
||||||
|
import com.example.architecture.core.design.system.component.LoadingIndicator
|
||||||
|
import com.example.architecture.core.design.system.component.NetworkImage
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.core.presentation.ObserveAsEvents
|
||||||
|
import com.example.architecture.core.presentation.asString
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListEvent
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
|
||||||
|
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharacterListRoot(
|
||||||
|
onCharacterClick: (Int) -> Unit,
|
||||||
|
viewModel: CharacterListViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ObserveAsEvents(viewModel.events) { event ->
|
||||||
|
when (event) {
|
||||||
|
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
|
||||||
|
is CharacterListEvent.ShowSnackbar -> scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(event.message.asString(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterListScreen(
|
||||||
|
state = state,
|
||||||
|
onAction = viewModel::onAction,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure, stateless screen — previewable without a ViewModel. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CharacterListScreen(
|
||||||
|
state: CharacterListState,
|
||||||
|
onAction: (CharacterListAction) -> Unit,
|
||||||
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
|
) {
|
||||||
|
AppScaffold(
|
||||||
|
topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) },
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
// Local val so the nullable cross-module `error` can smart-cast inside the branch.
|
||||||
|
val error = state.error
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator()
|
||||||
|
|
||||||
|
error != null && state.characters.isEmpty() -> ErrorState(
|
||||||
|
message = error.asString(),
|
||||||
|
onRetry = { onAction(CharacterListAction.OnRetry) },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.characters.isEmpty() -> EmptyState()
|
||||||
|
|
||||||
|
else -> CharacterList(state = state, onAction = onAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterList(
|
||||||
|
state: CharacterListState,
|
||||||
|
onAction: (CharacterListAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Trigger paging from the snapshot-backed list state only; the ViewModel guards against
|
||||||
|
// duplicate/just-loading/end-reached requests, so the composable stays simple.
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val total = layoutInfo.totalItemsCount
|
||||||
|
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
total > 0 && lastVisible >= total - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items = state.characters, key = { it.id }) { character ->
|
||||||
|
CharacterListItem(
|
||||||
|
character = character,
|
||||||
|
onClick = { onAction(CharacterListAction.OnCharacterClick(character.id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.isLoadingNextPage) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListItem(
|
||||||
|
character: CharacterUi,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AppCard(modifier = modifier.fillMaxWidth(), onClick = onClick) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
NetworkImage(
|
||||||
|
imageUrl = character.imageUrl,
|
||||||
|
contentDescription = stringResource(R.string.cd_character_avatar, character.name),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(text = character.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(character.status.indicatorColor(), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(character.status.labelRes()) + " · " + character.species,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.characters_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharacterStatus.labelRes(): Int = when (this) {
|
||||||
|
CharacterStatus.ALIVE -> R.string.status_alive
|
||||||
|
CharacterStatus.DEAD -> R.string.status_dead
|
||||||
|
CharacterStatus.UNKNOWN -> R.string.status_unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharacterStatus.indicatorColor(): Color = when (this) {
|
||||||
|
CharacterStatus.ALIVE -> Color(0xFF4CAF50)
|
||||||
|
CharacterStatus.DEAD -> Color(0xFFE53935)
|
||||||
|
CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewCharacters = persistentListOf(
|
||||||
|
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
CharacterUi(3, "Birdperson", "Bird-Person", "", CharacterStatus.DEAD),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListScreenLoadedPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterListScreen(
|
||||||
|
state = CharacterListState(characters = previewCharacters),
|
||||||
|
onAction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListScreenErrorPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterListScreen(
|
||||||
|
state = CharacterListState(
|
||||||
|
error = com.example.architecture.core.presentation.UiText.DynamicString(
|
||||||
|
"No internet connection.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onAction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Type-safe route for the characters list screen. */
|
||||||
|
@Serializable
|
||||||
|
data object CharacterListRoute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The characters feature nav graph. `:app` only calls this and supplies cross-screen navigation as
|
||||||
|
* a callback. The detail destination is added here in a later milestone.
|
||||||
|
*/
|
||||||
|
fun NavGraphBuilder.charactersGraph(
|
||||||
|
onCharacterClick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
composable<CharacterListRoute> {
|
||||||
|
CharacterListRoot(onCharacterClick = onCharacterClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="characters_title">Characters</string>
|
||||||
|
<string name="characters_empty">No characters to show.</string>
|
||||||
|
<string name="cd_character_avatar">Avatar of %1$s</string>
|
||||||
|
<string name="status_alive">Alive</string>
|
||||||
|
<string name="status_dead">Dead</string>
|
||||||
|
<string name="status_unknown">Unknown</string>
|
||||||
|
</resources>
|
||||||
@@ -17,4 +17,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
// Stable collection for state — makes the list Compose-stable WITHOUT a Compose dependency,
|
||||||
|
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
|
||||||
|
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
|
||||||
|
api(libs.kotlinx.collections.immutable)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
sealed interface CharacterListAction {
|
||||||
|
data class OnCharacterClick(val characterId: Int) : CharacterListAction
|
||||||
|
data object OnRetry : CharacterListAction
|
||||||
|
data object OnLoadNextPage : CharacterListAction
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.presentation.UiText
|
||||||
|
|
||||||
|
sealed interface CharacterListEvent {
|
||||||
|
data class NavigateToDetail(val characterId: Int) : CharacterListEvent
|
||||||
|
data class ShowSnackbar(val message: UiText) : CharacterListEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.presentation.UiText
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
|
||||||
|
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
|
||||||
|
* Compose already treats as stable — so this module needs no Compose dependency. Navigation and
|
||||||
|
* snackbars are one-time Events, never state.
|
||||||
|
*/
|
||||||
|
data class CharacterListState(
|
||||||
|
val characters: ImmutableList<CharacterUi> = persistentListOf(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isLoadingNextPage: Boolean = false,
|
||||||
|
val currentPage: Int = 1,
|
||||||
|
val endReached: Boolean = false,
|
||||||
|
val error: UiText? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.core.domain.onFailure
|
||||||
|
import com.example.architecture.core.domain.onSuccess
|
||||||
|
import com.example.architecture.core.presentation.UiText
|
||||||
|
import com.example.architecture.core.presentation.toUiText
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-agnostic MVI ViewModel for the characters list. Shared by BOTH the Compose and the Views
|
||||||
|
* renderers. Updates [CharacterListState] only via `.update`, emits one-time [CharacterListEvent]s
|
||||||
|
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
|
||||||
|
*/
|
||||||
|
class CharacterListViewModel(
|
||||||
|
private val characterRepository: CharacterRepository,
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(CharacterListState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = Channel<CharacterListEvent>()
|
||||||
|
val events = _events.receiveAsFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// After process death, rebuild the list up to the highest page that had been loaded.
|
||||||
|
val restoredPage: Int = savedStateHandle[KEY_PAGE] ?: 1
|
||||||
|
restore(restoredPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAction(action: CharacterListAction) {
|
||||||
|
when (action) {
|
||||||
|
is CharacterListAction.OnCharacterClick -> viewModelScope.launch {
|
||||||
|
_events.send(CharacterListEvent.NavigateToDetail(action.characterId))
|
||||||
|
}
|
||||||
|
CharacterListAction.OnRetry -> retry()
|
||||||
|
CharacterListAction.OnLoadNextPage -> loadNextPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restore(targetPage: Int) {
|
||||||
|
// Flip the flag synchronously so a guard reading state sees it immediately.
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val accumulated = mutableListOf<CharacterUi>()
|
||||||
|
var lastLoadedPage = 0
|
||||||
|
var endReached = false
|
||||||
|
var error: UiText? = null
|
||||||
|
|
||||||
|
var page = 1
|
||||||
|
while (page <= targetPage) {
|
||||||
|
when (val result = characterRepository.getCharacters(page)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
accumulated += result.data.characters.map { it.toCharacterUi() }
|
||||||
|
lastLoadedPage = page
|
||||||
|
endReached = result.data.nextPage == null
|
||||||
|
if (endReached) break
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
error = result.error.toUiText()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always surface a failure — even a partial one where earlier pages loaded.
|
||||||
|
if (error != null) {
|
||||||
|
_events.send(CharacterListEvent.ShowSnackbar(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accumulated.isEmpty()) {
|
||||||
|
// Nothing loaded → full-screen error (or an empty list if the API simply had none).
|
||||||
|
_state.update { it.copy(isLoading = false, error = error) }
|
||||||
|
} else {
|
||||||
|
val loadedPage = lastLoadedPage.coerceAtLeast(1)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
characters = accumulated.toImmutableList(),
|
||||||
|
isLoading = false,
|
||||||
|
currentPage = loadedPage,
|
||||||
|
endReached = endReached,
|
||||||
|
// The list is shown; the snackbar already surfaced any partial failure.
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
savedStateHandle[KEY_PAGE] = loadedPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadNextPage() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current.isLoading || current.isLoadingNextPage || current.endReached) return
|
||||||
|
loadPage(page = current.currentPage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retry() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current.characters.isEmpty()) {
|
||||||
|
restore(savedStateHandle[KEY_PAGE] ?: 1)
|
||||||
|
} else {
|
||||||
|
loadPage(page = current.currentPage + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPage(page: Int) {
|
||||||
|
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
|
||||||
|
// guarded out before its coroutine starts — otherwise the same page loads twice and items
|
||||||
|
// get appended twice.
|
||||||
|
_state.update { it.copy(isLoadingNextPage = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
characterRepository.getCharacters(page)
|
||||||
|
.onSuccess { pageData ->
|
||||||
|
_state.update { state ->
|
||||||
|
state.copy(
|
||||||
|
characters = (state.characters + pageData.characters.map { it.toCharacterUi() })
|
||||||
|
.toImmutableList(),
|
||||||
|
isLoadingNextPage = false,
|
||||||
|
currentPage = page,
|
||||||
|
endReached = pageData.nextPage == null,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
savedStateHandle[KEY_PAGE] = page
|
||||||
|
}
|
||||||
|
.onFailure { failure ->
|
||||||
|
val message = failure.toUiText()
|
||||||
|
_state.update { it.copy(isLoadingNextPage = false, error = message) }
|
||||||
|
_events.send(CharacterListEvent.ShowSnackbar(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_PAGE = "currentPage"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModel it provides. */
|
||||||
|
val charactersPresentationModule = module {
|
||||||
|
viewModelOf(::CharacterListViewModel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.model
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
|
||||||
|
/** Presentation model for a character list item — decouples the UI from the domain [Character]. */
|
||||||
|
data class CharacterUi(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val species: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val status: CharacterStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Character.toCharacterUi(): CharacterUi = CharacterUi(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
species = species,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
status = status,
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ composeBom = "2026.03.01"
|
|||||||
# Async / serialization
|
# Async / serialization
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
kotlinxSerialization = "1.8.1"
|
kotlinxSerialization = "1.8.1"
|
||||||
|
kotlinxCollectionsImmutable = "0.3.8"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
koin = "4.1.0"
|
koin = "4.1.0"
|
||||||
@@ -87,6 +88,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
|||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
||||||
|
|
||||||
# --- Koin (versions via BOM) ---
|
# --- Koin (versions via BOM) ---
|
||||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user