25
core/data/build.gradle.kts
Normal file
25
core/data/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.android.library)
|
||||
alias(libs.plugins.architecture.ktor)
|
||||
alias(libs.plugins.architecture.koin)
|
||||
}
|
||||
|
||||
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)
|
||||
// `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>
|
||||
@@ -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,128 @@
|
||||
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) {
|
||||
logNetworkError(e, "No internet (unresolved address)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: UnknownHostException) {
|
||||
logNetworkError(e, "No internet (unknown host)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: SerializationException) {
|
||||
logNetworkError(e, "Serialization failure")
|
||||
Result.Error(DataError.Network.SERIALIZATION)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body)
|
||||
// in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a
|
||||
// bad payload still maps to SERIALIZATION instead of the generic UNKNOWN.
|
||||
if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) {
|
||||
logNetworkError(e, "Serialization failure (wrapped)")
|
||||
Result.Error(DataError.Network.SERIALIZATION)
|
||||
} else {
|
||||
logNetworkError(e, "Unknown network failure")
|
||||
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] (covering 400/403/404 as well). */
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
core/design-system/build.gradle.kts
Normal file
14
core/design-system/build.gradle.kts
Normal file
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.android.library)
|
||||
alias(libs.plugins.architecture.compose)
|
||||
}
|
||||
|
||||
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>
|
||||
3
core/domain/build.gradle.kts
Normal file
3
core/domain/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.domain.module)
|
||||
}
|
||||
@@ -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 { }
|
||||
16
core/presentation/build.gradle.kts
Normal file
16
core/presentation/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.android.library)
|
||||
alias(libs.plugins.architecture.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.architecture.core.presentation"
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user