From 6bc4027cbb9a2ac3932b1a26586434ed9eb2b269 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:31:13 +0200 Subject: [PATCH 1/7] feat(core:domain): typed Result / Error / DataError core (REDI-80) - Error marker interface; Result (Success/Error) + EmptyResult typealias. - Inline chainable helpers: map / onSuccess / onFailure / asEmptyResult. - DataError sealed interface with full Network + Local case sets. - Pure Kotlin, zero Android imports. --- .../architecture/core/domain/DataError.kt | 29 ++++++++++++ .../example/architecture/core/domain/Error.kt | 7 +++ .../architecture/core/domain/Result.kt | 46 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 core/domain/src/main/kotlin/com/example/architecture/core/domain/DataError.kt create mode 100644 core/domain/src/main/kotlin/com/example/architecture/core/domain/Error.kt create mode 100644 core/domain/src/main/kotlin/com/example/architecture/core/domain/Result.kt diff --git a/core/domain/src/main/kotlin/com/example/architecture/core/domain/DataError.kt b/core/domain/src/main/kotlin/com/example/architecture/core/domain/DataError.kt new file mode 100644 index 0000000..db01008 --- /dev/null +++ b/core/domain/src/main/kotlin/com/example/architecture/core/domain/DataError.kt @@ -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, + } +} diff --git a/core/domain/src/main/kotlin/com/example/architecture/core/domain/Error.kt b/core/domain/src/main/kotlin/com/example/architecture/core/domain/Error.kt new file mode 100644 index 0000000..15f48b1 --- /dev/null +++ b/core/domain/src/main/kotlin/com/example/architecture/core/domain/Error.kt @@ -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 diff --git a/core/domain/src/main/kotlin/com/example/architecture/core/domain/Result.kt b/core/domain/src/main/kotlin/com/example/architecture/core/domain/Result.kt new file mode 100644 index 0000000..c50b076 --- /dev/null +++ b/core/domain/src/main/kotlin/com/example/architecture/core/domain/Result.kt @@ -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 { + data class Success(val data: D) : Result + + // The bound is fully qualified because inside this scope `Error` would resolve to this class. + data class Error( + val error: E, + ) : Result +} + +/** A [Result] that carries no success payload — for operations that either succeed or fail. */ +typealias EmptyResult = Result + +inline fun Result.map(map: (T) -> R): Result { + return when (this) { + is Result.Error -> Result.Error(error) + is Result.Success -> Result.Success(map(data)) + } +} + +inline fun Result.onSuccess(action: (T) -> Unit): Result { + return when (this) { + is Result.Error -> this + is Result.Success -> { + action(data) + this + } + } +} + +inline fun Result.onFailure(action: (E) -> Unit): Result { + return when (this) { + is Result.Error -> { + action(error) + this + } + is Result.Success -> this + } +} + +fun Result.asEmptyResult(): EmptyResult = map { } From 3a155beb3c5f39ac7ad0c828223ebd833d1b9b8e Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:37:31 +0200 Subject: [PATCH 2/7] feat(core:design-system): AppTheme + reusable composables (REDI-81) - AppTheme wraps Material3 (color scheme, typography, shapes); all previews use it. - Slot-API AppCard (header + content slots, optional click); AppScaffold. - LoadingIndicator, ErrorState (optional retry), Coil-backed NetworkImage. - Modifier.shimmerEffect() animated placeholder (Modifier extension, not @Composable). - Add androidx-compose-foundation to the version catalog + compose bundle. --- .../convention/ComposeConventionPlugin.kt | 3 + core/design-system/build.gradle.kts | 6 ++ .../core/design/system/component/AppCard.kt | 58 +++++++++++++++++++ .../design/system/component/AppScaffold.kt | 23 ++++++++ .../design/system/component/ErrorState.kt | 56 ++++++++++++++++++ .../system/component/LoadingIndicator.kt | 23 ++++++++ .../design/system/component/NetworkImage.kt | 31 ++++++++++ .../design/system/modifier/ShimmerEffect.kt | 49 ++++++++++++++++ .../core/design/system/theme/Color.kt | 45 ++++++++++++++ .../core/design/system/theme/Shape.kt | 11 ++++ .../core/design/system/theme/Theme.kt | 22 +++++++ .../core/design/system/theme/Type.kt | 6 ++ .../src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 2 + 14 files changed, 338 insertions(+) create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt create mode 100644 core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt create mode 100644 core/design-system/src/main/res/values/strings.xml diff --git a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt index 44c7274..79571df 100644 --- a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt @@ -26,6 +26,9 @@ class ComposeConventionPlugin : Plugin { } 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) diff --git a/core/design-system/build.gradle.kts b/core/design-system/build.gradle.kts index 08c4377..91d3610 100644 --- a/core/design-system/build.gradle.kts +++ b/core/design-system/build.gradle.kts @@ -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) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt new file mode 100644 index 0000000..531a908 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt @@ -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, + ) + } + } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt new file mode 100644 index 0000000..57c958f --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt new file mode 100644 index 0000000..12fa43e --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt @@ -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 = {}) + } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt new file mode 100644 index 0000000..a5f7ee5 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt @@ -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() } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt new file mode 100644 index 0000000..534f1e1 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt new file mode 100644 index 0000000..8fd35e2 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt @@ -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 } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt new file mode 100644 index 0000000..505b7d1 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt @@ -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, +) diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt new file mode 100644 index 0000000..db7f6e3 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt @@ -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), +) diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt new file mode 100644 index 0000000..97b07f8 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt new file mode 100644 index 0000000..116020d --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt @@ -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() diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml new file mode 100644 index 0000000..11f738c --- /dev/null +++ b/core/design-system/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Retry + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d75c6e9..5ee5428 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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", ] From 709c7d6ff5f7617780539474e12a6bbb407ab225 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:42:38 +0200 Subject: [PATCH 3/7] feat(core:presentation): UiText, ObserveAsEvents, DataError -> UiText (REDI-82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UiText sealed interface (DynamicString / StringResource) — Compose-free type so the UI-agnostic presentation module can hold UiText? in state without depending on Compose. - Two resolvers: @Composable UiText.asString() (Compose renderer) and UiText.asString(context) (Views renderer). - ObserveAsEvents: lifecycle-aware one-time event collection on Main.immediate. - DataError.toUiText() covering all displayed cases with else -> unknown; error strings here. --- core/presentation/build.gradle.kts | 4 +++ .../core/presentation/DataErrorExt.kt | 28 +++++++++++++++++ .../core/presentation/ObserveAsEvents.kt | 31 +++++++++++++++++++ .../architecture/core/presentation/UiText.kt | 19 ++++++++++++ .../core/presentation/UiTextExt.kt | 18 +++++++++++ .../src/main/res/values/strings.xml | 16 ++++++++++ 6 files changed, 116 insertions(+) create mode 100644 core/presentation/src/main/kotlin/com/example/architecture/core/presentation/DataErrorExt.kt create mode 100644 core/presentation/src/main/kotlin/com/example/architecture/core/presentation/ObserveAsEvents.kt create mode 100644 core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiText.kt create mode 100644 core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiTextExt.kt create mode 100644 core/presentation/src/main/res/values/strings.xml diff --git a/core/presentation/build.gradle.kts b/core/presentation/build.gradle.kts index a990f0b..aeae61a 100644 --- a/core/presentation/build.gradle.kts +++ b/core/presentation/build.gradle.kts @@ -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) } diff --git a/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/DataErrorExt.kt b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/DataErrorExt.kt new file mode 100644 index 0000000..46a82a5 --- /dev/null +++ b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/DataErrorExt.kt @@ -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) +} diff --git a/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/ObserveAsEvents.kt b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/ObserveAsEvents.kt new file mode 100644 index 0000000..a13c404 --- /dev/null +++ b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/ObserveAsEvents.kt @@ -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 ObserveAsEvents( + flow: Flow, + 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) + } + } + } +} diff --git a/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiText.kt b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiText.kt new file mode 100644 index 0000000..e569e8d --- /dev/null +++ b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiText.kt @@ -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 = emptyArray(), + ) : UiText +} diff --git a/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiTextExt.kt b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiTextExt.kt new file mode 100644 index 0000000..5bf3a6b --- /dev/null +++ b/core/presentation/src/main/kotlin/com/example/architecture/core/presentation/UiTextExt.kt @@ -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) +} diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000..bb5e41a --- /dev/null +++ b/core/presentation/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + No internet connection. Check your network and try again. + The request timed out. Please try again. + You are not authorized. Please sign in again. + You don\'t have permission to do that. + We couldn\'t find what you were looking for. + That action conflicts with the current state. + Too many requests. Please slow down and try again. + The request was too large. + Something went wrong on our end. Please try again later. + The service is temporarily unavailable. Please try again later. + We received an unexpected response. Please try again later. + The request was invalid. + Your device is out of storage space. + Something went wrong. Please try again. + From 5f3cc51195a31bd7861c1be70daf02dd7ca5eebb Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:45:53 +0200 Subject: [PATCH 4/7] feat(core:data): Ktor network core + coreDataModule (REDI-83) - HttpClientFactory.create(engine) with the engine injected (MockEngine seam for tests): ContentNegotiation JSON (ignoreUnknownKeys), Kermit-backed Ktor logging, default JSON request. - safeCall / responseToResult (status -> DataError.Network, extended with 400/403/404/503) / constructRoute (reads BuildConfig.BASE_URL) and typed HttpClient.get/post/delete. - BASE_URL BuildConfig field = Rick & Morty API. - coreDataModule: single via factory lambda (the one sanctioned lambda-DSL binding). --- core/data/build.gradle.kts | 9 ++ .../core/data/di/CoreDataModule.kt | 15 +++ .../core/data/network/HttpClientExt.kt | 107 ++++++++++++++++++ .../core/data/network/HttpClientFactory.kt | 43 +++++++ 4 files changed, 174 insertions(+) create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 6ef43a2..c9d7554 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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.kermit) } diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt new file mode 100644 index 0000000..4ab8b4d --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt @@ -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 { HttpClientFactory.create(OkHttp.create()) } +} diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt new file mode 100644 index 0000000..b6fa067 --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -0,0 +1,107 @@ +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 java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException +import kotlin.coroutines.cancellation.CancellationException +import co.touchlab.kermit.Logger as KermitLogger + +suspend inline fun HttpClient.get( + route: String, + queryParameters: Map = emptyMap(), +): Result { + return safeCall { + get { + url(constructRoute(route)) + queryParameters.forEach { (key, value) -> parameter(key, value) } + } + } +} + +suspend inline fun HttpClient.post( + route: String, + body: Request, +): Result { + return safeCall { + post { + url(constructRoute(route)) + setBody(body) + } + } +} + +suspend inline fun HttpClient.delete( + route: String, + queryParameters: Map = emptyMap(), +): Result { + return safeCall { + delete { + url(constructRoute(route)) + queryParameters.forEach { (key, value) -> parameter(key, value) } + } + } +} + +/** Wraps a Ktor call, turning transport exceptions into typed [DataError.Network] results. */ +suspend inline fun safeCall( + execute: () -> HttpResponse, +): Result { + val response = try { + execute() + } catch (e: UnresolvedAddressException) { + KermitLogger.withTag("HttpClient").e(e) { "No internet (unresolved address)" } + return Result.Error(DataError.Network.NO_INTERNET) + } catch (e: UnknownHostException) { + KermitLogger.withTag("HttpClient").e(e) { "No internet (unknown host)" } + return Result.Error(DataError.Network.NO_INTERNET) + } catch (e: SerializationException) { + KermitLogger.withTag("HttpClient").e(e) { "Serialization failure" } + return Result.Error(DataError.Network.SERIALIZATION) + } catch (e: Exception) { + if (e is CancellationException) throw e + KermitLogger.withTag("HttpClient").e(e) { "Unknown network failure" } + return Result.Error(DataError.Network.UNKNOWN) + } + return responseToResult(response) +} + +/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */ +suspend inline fun responseToResult( + response: HttpResponse, +): Result { + return when (response.status.value) { + in 200..299 -> Result.Success(response.body()) + 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" + } +} diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt new file mode 100644 index 0000000..49268ec --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt @@ -0,0 +1,43 @@ +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 co.touchlab.kermit.Logger as KermitLogger +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`). + */ +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) { + KermitLogger.withTag("HttpClient").d(message) + } + } + level = LogLevel.ALL + } + defaultRequest { + contentType(ContentType.Application.Json) + } + } + } +} From 070ffde49c45f3326edac3e472b5ebe366c4da8e Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:48:19 +0200 Subject: [PATCH 5/7] feat(app): Koin bootstrap + AppTheme + Material3 XML theme (REDI-84) - ArchitectureApp.Application: startKoin { androidLogger; androidContext; modules(coreDataModule) }. Modules are assembled only here; feature modules will append to the list. - MainActivity hosts a themed empty screen via AppTheme + AppScaffold (design-system). - Activity XML theme upgraded to Theme.Material3.DayNight.NoActionBar (Compose themes via AppTheme; the Material3 XML theme lets the later Views renderer inherit Material3 styling). - :app depends on :core:data + :core:design-system; applies the koin convention. --- app/build.gradle.kts | 7 ++++++ app/src/main/AndroidManifest.xml | 1 + .../example/architecture/ArchitectureApp.kt | 24 +++++++++++++++++++ .../com/example/architecture/MainActivity.kt | 14 +++++------ app/src/main/res/values/themes.xml | 8 +++---- 5 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5469aab..6eae0a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,13 +1,20 @@ plugins { alias(libs.plugins.architecture.android.application) alias(libs.plugins.architecture.compose) + alias(libs.plugins.architecture.koin) } 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) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 778f466..c10b0e4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + // Compose themes via AppTheme; the navigation host lands in a later milestone. + AppTheme { + AppScaffold { innerPadding -> Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 0376602..1a5fe7e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,9 @@ -