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:
@@ -9,4 +9,8 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
|
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
16
core/presentation/src/main/res/values/strings.xml
Normal file
16
core/presentation/src/main/res/values/strings.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user