From 709c7d6ff5f7617780539474e12a6bbb407ab225 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:42:38 +0200 Subject: [PATCH] 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. +