Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

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

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

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

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

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

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,3 @@
plugins {
alias(libs.plugins.architecture.domain.module)
}

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

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

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>