Merge pull request #1 from AdrianKuta/feat/core-infrastructure
Core Infrastructure (REDI-80…84)
This commit is contained in:
@@ -1,13 +1,29 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.architecture.android.application)
|
alias(libs.plugins.architecture.android.application)
|
||||||
alias(libs.plugins.architecture.compose)
|
alias(libs.plugins.architecture.compose)
|
||||||
|
alias(libs.plugins.architecture.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
// Needed for BuildConfig.DEBUG (gating the Timber DebugTree).
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// :app is the only place modules are assembled and the dependency graph is wired.
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
implementation(project(":core:design-system"))
|
||||||
|
|
||||||
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)
|
||||||
|
// Material Components — required for the Material3 XML Activity theme.
|
||||||
|
implementation(libs.material)
|
||||||
|
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
||||||
|
implementation(libs.timber)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".ArchitectureApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.architecture
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.example.architecture.core.data.di.coreDataModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single Koin entry point. Feature modules append their own `*DataModule` / `*PresentationModule`
|
||||||
|
* to the [modules] list — assembly happens only here, never inside feature modules.
|
||||||
|
*/
|
||||||
|
class ArchitectureApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// Plant Timber only in debug; release builds get no logs (swap in a crash-reporting tree).
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
}
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidLogger()
|
||||||
|
androidContext(this@ArchitectureApp)
|
||||||
|
modules(
|
||||||
|
coreDataModule,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,24 @@ package com.example.architecture
|
|||||||
import android.os.Bundle
|
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.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
// Placeholder content. The real navigation host + AppTheme are wired in later
|
// Compose themes via AppTheme; the navigation host lands in a later milestone.
|
||||||
// milestones (design-system, characters graph, Koin bootstrap).
|
AppTheme {
|
||||||
MaterialTheme {
|
AppScaffold { innerPadding ->
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!--
|
<!--
|
||||||
Compose drives the in-app theming via AppTheme (core:design-system). This XML theme only
|
Compose drives the in-app theming via AppTheme (core:design-system); this XML theme styles
|
||||||
styles the Activity window. It is upgraded to a Material3 (Theme.Material3.*) parent in the
|
the Activity window. It uses a Material3 parent so the Views renderer hosted later (via
|
||||||
Koin-bootstrap milestone so the later hosted Views renderer inherits Material3 styling.
|
Compose<->View interop) inherits Material3 styling.
|
||||||
-->
|
-->
|
||||||
<style name="Theme.AndroidArchitectureShowcase" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.AndroidArchitectureShowcase" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class ComposeConventionPlugin : Plugin<Project> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// `implementation` (not api): every Compose consumer applies this convention itself, so
|
||||||
|
// Compose must NOT leak transitively — that keeps the UI-agnostic presentation module
|
||||||
|
// (which depends on core:presentation) free of Compose.
|
||||||
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
|
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
|
||||||
add("implementation", bom)
|
add("implementation", bom)
|
||||||
add("androidTestImplementation", bom)
|
add("androidTestImplementation", bom)
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.architecture.core.data"
|
namespace = "com.example.architecture.core.data"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
defaultConfig {
|
||||||
|
// The no-key Rick & Morty API. constructRoute() reads this BuildConfig field.
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://rickandmortyapi.com/api\"")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
|
implementation(libs.timber)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.architecture.core.data.di
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.network.HttpClientFactory
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding —
|
||||||
|
* HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the
|
||||||
|
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
|
||||||
|
*/
|
||||||
|
val coreDataModule = module {
|
||||||
|
single<HttpClient> { HttpClientFactory.create(OkHttp.create()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.example.architecture.core.data.network
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.BuildConfig
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.delete
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.request.url
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.nio.channels.UnresolvedAddressException
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
suspend inline fun <reified Response : Any> HttpClient.get(
|
||||||
|
route: String,
|
||||||
|
queryParameters: Map<String, Any?> = emptyMap(),
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
get {
|
||||||
|
url(constructRoute(route))
|
||||||
|
queryParameters.forEach { (key, value) -> parameter(key, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified Request, reified Response : Any> HttpClient.post(
|
||||||
|
route: String,
|
||||||
|
body: Request,
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
post {
|
||||||
|
url(constructRoute(route))
|
||||||
|
setBody(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified Response : Any> HttpClient.delete(
|
||||||
|
route: String,
|
||||||
|
queryParameters: Map<String, Any?> = emptyMap(),
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
delete {
|
||||||
|
url(constructRoute(route))
|
||||||
|
queryParameters.forEach { (key, value) -> parameter(key, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a Ktor call AND its response deserialization, turning transport/parse exceptions into typed
|
||||||
|
* [DataError.Network] results. `responseToResult` runs inside the try so a malformed 2xx body maps
|
||||||
|
* to SERIALIZATION instead of escaping uncaught.
|
||||||
|
*/
|
||||||
|
suspend inline fun <reified T> safeCall(
|
||||||
|
execute: () -> HttpResponse,
|
||||||
|
): Result<T, DataError.Network> {
|
||||||
|
return try {
|
||||||
|
responseToResult(execute())
|
||||||
|
} catch (e: UnresolvedAddressException) {
|
||||||
|
Timber.tag("HttpClient").e(e, "No internet (unresolved address)")
|
||||||
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
|
} catch (e: UnknownHostException) {
|
||||||
|
Timber.tag("HttpClient").e(e, "No internet (unknown host)")
|
||||||
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
Timber.tag("HttpClient").e(e, "Serialization failure")
|
||||||
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Timber.tag("HttpClient").e(e, "Unknown network failure")
|
||||||
|
Result.Error(DataError.Network.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
|
||||||
|
suspend inline fun <reified T> responseToResult(
|
||||||
|
response: HttpResponse,
|
||||||
|
): Result<T, DataError.Network> {
|
||||||
|
return when (response.status.value) {
|
||||||
|
in 200..299 -> Result.Success(response.body<T>())
|
||||||
|
400 -> Result.Error(DataError.Network.BAD_REQUEST)
|
||||||
|
401 -> Result.Error(DataError.Network.UNAUTHORIZED)
|
||||||
|
403 -> Result.Error(DataError.Network.FORBIDDEN)
|
||||||
|
404 -> Result.Error(DataError.Network.NOT_FOUND)
|
||||||
|
408 -> Result.Error(DataError.Network.REQUEST_TIMEOUT)
|
||||||
|
409 -> Result.Error(DataError.Network.CONFLICT)
|
||||||
|
413 -> Result.Error(DataError.Network.PAYLOAD_TOO_LARGE)
|
||||||
|
429 -> Result.Error(DataError.Network.TOO_MANY_REQUESTS)
|
||||||
|
503 -> Result.Error(DataError.Network.SERVICE_UNAVAILABLE)
|
||||||
|
in 500..599 -> Result.Error(DataError.Network.SERVER_ERROR)
|
||||||
|
else -> Result.Error(DataError.Network.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepends [BuildConfig.BASE_URL] unless [route] is already absolute. */
|
||||||
|
fun constructRoute(route: String): String {
|
||||||
|
return when {
|
||||||
|
route.contains(BuildConfig.BASE_URL) -> route
|
||||||
|
route.startsWith("/") -> BuildConfig.BASE_URL + route
|
||||||
|
else -> BuildConfig.BASE_URL + "/$route"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.architecture.core.data.network
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.HttpClientEngine
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
|
import io.ktor.client.plugins.logging.Logger as KtorLogger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the app's single [HttpClient]. The [engine] is injected so tests can pass a Ktor
|
||||||
|
* `MockEngine` while production passes OkHttp (see `coreDataModule`). Ktor logging is bridged to
|
||||||
|
* Timber so all logs flow through one tree (planted in the Application).
|
||||||
|
*/
|
||||||
|
object HttpClientFactory {
|
||||||
|
fun create(engine: HttpClientEngine): HttpClient {
|
||||||
|
return HttpClient(engine) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = object : KtorLogger {
|
||||||
|
override fun log(message: String) {
|
||||||
|
Timber.tag("HttpClient").d(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = LogLevel.ALL
|
||||||
|
}
|
||||||
|
defaultRequest {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,9 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.example.architecture.core.design.system"
|
namespace = "com.example.architecture.core.design.system"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Coil is internal to NetworkImage; no Coil types leak into public signatures.
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.okhttp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot-API card. Callers compose into an optional [header] slot and the [content] slot
|
||||||
|
* (a `ColumnScope`), and may make the whole card clickable. Feature code decides what goes inside.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
header: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val body: @Composable ColumnScope.() -> Unit = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
header?.invoke()
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onClick != null) {
|
||||||
|
Card(onClick = onClick, modifier = modifier, content = body)
|
||||||
|
} else {
|
||||||
|
Card(modifier = modifier, content = body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun AppCardPreview() {
|
||||||
|
AppTheme {
|
||||||
|
AppCard(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
onClick = {},
|
||||||
|
header = { Text("Rick Sanchez", style = MaterialTheme.typography.titleMedium) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Human · Alive · Earth (C-137)",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over [Scaffold] giving screens a consistent surface. Slot API: callers provide the
|
||||||
|
* [topBar] and the [content] (which receives the inner [PaddingValues] to consume).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
topBar: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = topBar,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
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.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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 com.example.architecture.core.design.system.R
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered error message with an optional retry button. The message is already-resolved text
|
||||||
|
* (the caller maps its error/`UiText` to a String); the retry label is localized here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ErrorState(
|
||||||
|
message: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (onRetry != null) {
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text(text = stringResource(R.string.designsystem_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ErrorStatePreview() {
|
||||||
|
AppTheme {
|
||||||
|
ErrorState(message = "No internet connection.", onRetry = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun LoadingIndicatorPreview() {
|
||||||
|
AppTheme { LoadingIndicator() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.crossfade
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coil-backed remote image. Coil 3 auto-registers the OkHttp network fetcher from
|
||||||
|
* `coil-network-okhttp` on the classpath, so callers just pass a URL.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NetworkImage(
|
||||||
|
imageUrl: String?,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Crop,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(imageUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.example.architecture.core.design.system.modifier
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated placeholder shimmer for loading skeletons. Implemented as a `Modifier` extension (not a
|
||||||
|
* `@Composable`); `composed` lets it read the theme and animate while the gradient is repainted
|
||||||
|
* below the recomposition layer via [background].
|
||||||
|
*/
|
||||||
|
fun Modifier.shimmerEffect(): Modifier = composed {
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val startOffsetX by transition.animateFloat(
|
||||||
|
initialValue = -2f * size.width,
|
||||||
|
targetValue = 2f * size.width,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200),
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "shimmerOffsetX",
|
||||||
|
)
|
||||||
|
|
||||||
|
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val highlight = MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(base, highlight, base),
|
||||||
|
start = Offset(startOffsetX, 0f),
|
||||||
|
end = Offset(startOffsetX + size.width, size.height.toFloat()),
|
||||||
|
),
|
||||||
|
).onGloballyPositioned { size = it.size }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Brand palette — seeded from the Android green used by the project.
|
||||||
|
private val Green10 = Color(0xFF00210B)
|
||||||
|
private val Green20 = Color(0xFF003918)
|
||||||
|
private val Green40 = Color(0xFF1E6C36)
|
||||||
|
private val Green80 = Color(0xFF8FD89B)
|
||||||
|
private val Green90 = Color(0xFFAAF5B5)
|
||||||
|
|
||||||
|
private val Teal40 = Color(0xFF36687A)
|
||||||
|
private val Teal80 = Color(0xFF9ECEE3)
|
||||||
|
|
||||||
|
private val Neutral10 = Color(0xFF191C1A)
|
||||||
|
private val Neutral90 = Color(0xFFE1E3DE)
|
||||||
|
private val Neutral99 = Color(0xFFFBFDF7)
|
||||||
|
|
||||||
|
internal val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Green40,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Green90,
|
||||||
|
onPrimaryContainer = Green10,
|
||||||
|
secondary = Teal40,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
background = Neutral99,
|
||||||
|
onBackground = Neutral10,
|
||||||
|
surface = Neutral99,
|
||||||
|
onSurface = Neutral10,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Green80,
|
||||||
|
onPrimary = Green20,
|
||||||
|
primaryContainer = Green40,
|
||||||
|
onPrimaryContainer = Green90,
|
||||||
|
secondary = Teal80,
|
||||||
|
onSecondary = Neutral10,
|
||||||
|
background = Neutral10,
|
||||||
|
onBackground = Neutral90,
|
||||||
|
surface = Neutral10,
|
||||||
|
onSurface = Neutral90,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
internal val AppShapes = Shapes(
|
||||||
|
small = RoundedCornerShape(8.dp),
|
||||||
|
medium = RoundedCornerShape(12.dp),
|
||||||
|
large = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Compose theme for the app. Every screen and every `@Preview` is wrapped in this so
|
||||||
|
* they reflect real appearance. Dynamic color is intentionally off to keep the brand identity.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||||
|
typography = AppTypography,
|
||||||
|
shapes = AppShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
|
||||||
|
// Material3 baseline type scale. Swap in custom font families here if the brand needs them.
|
||||||
|
internal val AppTypography = Typography()
|
||||||
3
core/design-system/src/main/res/values/strings.xml
Normal file
3
core/design-system/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="designsystem_retry">Retry</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors raised by the data layer. [Network] for remote calls, [Local] for on-device storage.
|
||||||
|
* A repository that merges multiple sources can expose the [DataError] supertype.
|
||||||
|
*/
|
||||||
|
sealed interface DataError : Error {
|
||||||
|
enum class Network : DataError {
|
||||||
|
BAD_REQUEST,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
UNAUTHORIZED,
|
||||||
|
FORBIDDEN,
|
||||||
|
NOT_FOUND,
|
||||||
|
CONFLICT,
|
||||||
|
TOO_MANY_REQUESTS,
|
||||||
|
NO_INTERNET,
|
||||||
|
PAYLOAD_TOO_LARGE,
|
||||||
|
SERVER_ERROR,
|
||||||
|
SERVICE_UNAVAILABLE,
|
||||||
|
SERIALIZATION,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Local : DataError {
|
||||||
|
DISK_FULL,
|
||||||
|
NOT_FOUND,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker for every typed error in the app. Each layer/feature defines its own [Error]
|
||||||
|
* implementations (e.g. [DataError], or a feature validation enum) and pairs them with [Result].
|
||||||
|
*/
|
||||||
|
interface Error
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed result usable across every layer (data, domain, presentation, validation). Carries either
|
||||||
|
* success [data] or a typed [Error]. Prefer this over throwing for expected failures.
|
||||||
|
*/
|
||||||
|
sealed interface Result<out D, out E : Error> {
|
||||||
|
data class Success<out D>(val data: D) : Result<D, Nothing>
|
||||||
|
|
||||||
|
// The bound is fully qualified because inside this scope `Error` would resolve to this class.
|
||||||
|
data class Error<out E : com.example.architecture.core.domain.Error>(
|
||||||
|
val error: E,
|
||||||
|
) : Result<Nothing, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A [Result] that carries no success payload — for operations that either succeed or fail. */
|
||||||
|
typealias EmptyResult<E> = Result<Unit, E>
|
||||||
|
|
||||||
|
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> Result.Error(error)
|
||||||
|
is Result.Success -> Result.Success(map(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onSuccess(action: (T) -> Unit): Result<T, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> this
|
||||||
|
is Result.Success -> {
|
||||||
|
action(data)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onFailure(action: (E) -> Unit): Result<T, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> {
|
||||||
|
action(error)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
is Result.Success -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T, E : Error> Result<T, E>.asEmptyResult(): EmptyResult<E> = map { }
|
||||||
@@ -9,4 +9,8 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
|
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a [DataError] to user-facing [UiText]. Every displayed case has its own message; anything
|
||||||
|
* else (including the explicit `UNKNOWN` cases) falls back to a generic message.
|
||||||
|
*/
|
||||||
|
fun DataError.toUiText(): UiText {
|
||||||
|
val resId = when (this) {
|
||||||
|
DataError.Network.NO_INTERNET -> R.string.error_no_internet
|
||||||
|
DataError.Network.REQUEST_TIMEOUT -> R.string.error_request_timeout
|
||||||
|
DataError.Network.UNAUTHORIZED -> R.string.error_unauthorized
|
||||||
|
DataError.Network.FORBIDDEN -> R.string.error_forbidden
|
||||||
|
DataError.Network.NOT_FOUND -> R.string.error_not_found
|
||||||
|
DataError.Network.CONFLICT -> R.string.error_conflict
|
||||||
|
DataError.Network.TOO_MANY_REQUESTS -> R.string.error_too_many_requests
|
||||||
|
DataError.Network.PAYLOAD_TOO_LARGE -> R.string.error_payload_too_large
|
||||||
|
DataError.Network.SERVER_ERROR -> R.string.error_server
|
||||||
|
DataError.Network.SERVICE_UNAVAILABLE -> R.string.error_service_unavailable
|
||||||
|
DataError.Network.SERIALIZATION -> R.string.error_serialization
|
||||||
|
DataError.Network.BAD_REQUEST -> R.string.error_bad_request
|
||||||
|
DataError.Local.DISK_FULL -> R.string.error_disk_full
|
||||||
|
DataError.Local.NOT_FOUND -> R.string.error_not_found
|
||||||
|
else -> R.string.error_unknown
|
||||||
|
}
|
||||||
|
return UiText.StringResource(resId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects one-time [Flow] events (navigation, snackbars) lifecycle-awarely: only while the
|
||||||
|
* lifecycle is at least STARTED, and on `Main.immediate` so no event is missed during setup.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ObserveAsEvents(
|
||||||
|
flow: Flow<T>,
|
||||||
|
key1: Any? = null,
|
||||||
|
key2: Any? = null,
|
||||||
|
onEvent: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
LaunchedEffect(flow, lifecycleOwner.lifecycle, key1, key2) {
|
||||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
flow.collect(onEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string the UI will show that either is already concrete ([DynamicString]) or comes from a
|
||||||
|
* string resource ([StringResource], so it can be localized). The type itself is Compose-free, so a
|
||||||
|
* UI-agnostic ViewModel can hold `UiText?` in its state without depending on Compose; the actual
|
||||||
|
* resolution happens in the renderer via [asString].
|
||||||
|
*/
|
||||||
|
sealed interface UiText {
|
||||||
|
data class DynamicString(val value: String) : UiText
|
||||||
|
|
||||||
|
// Not a data class: Array has no structural equals. Compare by identity, like the framework does.
|
||||||
|
class StringResource(
|
||||||
|
@param:StringRes val id: Int,
|
||||||
|
val args: Array<Any> = emptyArray(),
|
||||||
|
) : UiText
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
/** Resolves to a [String] inside Compose (used by the Compose renderer). */
|
||||||
|
@Composable
|
||||||
|
fun UiText.asString(): String = when (this) {
|
||||||
|
is UiText.DynamicString -> value
|
||||||
|
is UiText.StringResource -> stringResource(id, *args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves to a [String] with a plain [Context] (used by the Views/XML renderer). */
|
||||||
|
fun UiText.asString(context: Context): String = when (this) {
|
||||||
|
is UiText.DynamicString -> value
|
||||||
|
is UiText.StringResource -> context.getString(id, *args)
|
||||||
|
}
|
||||||
16
core/presentation/src/main/res/values/strings.xml
Normal file
16
core/presentation/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="error_no_internet">No internet connection. Check your network and try again.</string>
|
||||||
|
<string name="error_request_timeout">The request timed out. Please try again.</string>
|
||||||
|
<string name="error_unauthorized">You are not authorized. Please sign in again.</string>
|
||||||
|
<string name="error_forbidden">You don\'t have permission to do that.</string>
|
||||||
|
<string name="error_not_found">We couldn\'t find what you were looking for.</string>
|
||||||
|
<string name="error_conflict">That action conflicts with the current state.</string>
|
||||||
|
<string name="error_too_many_requests">Too many requests. Please slow down and try again.</string>
|
||||||
|
<string name="error_payload_too_large">The request was too large.</string>
|
||||||
|
<string name="error_server">Something went wrong on our end. Please try again later.</string>
|
||||||
|
<string name="error_service_unavailable">The service is temporarily unavailable. Please try again later.</string>
|
||||||
|
<string name="error_serialization">We received an unexpected response. Please try again later.</string>
|
||||||
|
<string name="error_bad_request">The request was invalid.</string>
|
||||||
|
<string name="error_disk_full">Your device is out of storage space.</string>
|
||||||
|
<string name="error_unknown">Something went wrong. Please try again.</string>
|
||||||
|
</resources>
|
||||||
@@ -29,7 +29,7 @@ ktor = "3.1.3"
|
|||||||
coil = "3.1.0"
|
coil = "3.1.0"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
kermit = "2.0.5"
|
timber = "5.0.1"
|
||||||
|
|
||||||
# Material Components (Views renderer)
|
# Material Components (Views renderer)
|
||||||
material = "1.12.0"
|
material = "1.12.0"
|
||||||
@@ -74,6 +74,7 @@ material = { module = "com.google.android.material:material", version.ref = "mat
|
|||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||||
|
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
@@ -109,7 +110,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
|
|||||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||||
|
|
||||||
# --- Logging ---
|
# --- Logging ---
|
||||||
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||||
|
|
||||||
# --- Testing ---
|
# --- Testing ---
|
||||||
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
||||||
@@ -127,6 +128,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
|
|||||||
compose = [
|
compose = [
|
||||||
"androidx-compose-ui",
|
"androidx-compose-ui",
|
||||||
"androidx-compose-ui-graphics",
|
"androidx-compose-ui-graphics",
|
||||||
|
"androidx-compose-foundation",
|
||||||
"androidx-compose-ui-tooling-preview",
|
"androidx-compose-ui-tooling-preview",
|
||||||
"androidx-compose-material3",
|
"androidx-compose-material3",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user