Merge pull request #1 from AdrianKuta/feat/core-infrastructure

Core Infrastructure (REDI-80…84)
This commit is contained in:
2026-06-10 12:29:56 +02:00
committed by GitHub
32 changed files with 775 additions and 13 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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,
)
}
}
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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()) }
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}

View File

@@ -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 = {})
}
}

View File

@@ -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() }
}

View File

@@ -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,
)
}

View File

@@ -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 }
}

View File

@@ -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,
)

View File

@@ -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),
)

View File

@@ -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,
)
}

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
<resources>
<string name="designsystem_retry">Retry</string>
</resources>

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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 { }

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
}

View 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>

View File

@@ -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",
]