feat(core:presentation): UiText, ObserveAsEvents, DataError -> UiText (REDI-82)

- 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.
This commit is contained in:
2026-06-10 11:42:38 +02:00
parent 3a155beb3c
commit 709c7d6ff5
6 changed files with 116 additions and 0 deletions

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>