Merge pull request #1 from AdrianKuta/feat/core-infrastructure
Core Infrastructure (REDI-80…84)
This commit is contained in:
@@ -1,13 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.android.application)
|
||||
alias(libs.plugins.architecture.compose)
|
||||
alias(libs.plugins.architecture.koin)
|
||||
}
|
||||
|
||||
android {
|
||||
// Needed for BuildConfig.DEBUG (gating the Timber DebugTree).
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
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.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
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)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".ArchitectureApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
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 androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
// Placeholder content. The real navigation host + AppTheme are wired in later
|
||||
// milestones (design-system, characters graph, Koin bootstrap).
|
||||
MaterialTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
// Compose themes via AppTheme; the navigation host lands in a later milestone.
|
||||
AppTheme {
|
||||
AppScaffold { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Compose drives the in-app theming via AppTheme (core:design-system). This XML theme only
|
||||
styles the Activity window. It is upgraded to a Material3 (Theme.Material3.*) parent in the
|
||||
Koin-bootstrap milestone so the later hosted Views renderer inherits Material3 styling.
|
||||
Compose drives the in-app theming via AppTheme (core:design-system); this XML theme styles
|
||||
the Activity window. It uses a Material3 parent so the Views renderer hosted later (via
|
||||
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>
|
||||
|
||||
@@ -26,6 +26,9 @@ class ComposeConventionPlugin : Plugin<Project> {
|
||||
}
|
||||
|
||||
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())
|
||||
add("implementation", bom)
|
||||
add("androidTestImplementation", bom)
|
||||
|
||||
@@ -6,8 +6,17 @@ plugins {
|
||||
|
||||
android {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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"
|
||||
|
||||
# Logging
|
||||
kermit = "2.0.5"
|
||||
timber = "5.0.1"
|
||||
|
||||
# Material Components (Views renderer)
|
||||
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-ui = { module = "androidx.compose.ui:ui" }
|
||||
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-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
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" }
|
||||
|
||||
# --- Logging ---
|
||||
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||
|
||||
# --- Testing ---
|
||||
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
||||
@@ -127,6 +128,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
|
||||
compose = [
|
||||
"androidx-compose-ui",
|
||||
"androidx-compose-ui-graphics",
|
||||
"androidx-compose-foundation",
|
||||
"androidx-compose-ui-tooling-preview",
|
||||
"androidx-compose-material3",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user