In a viewModel, it may be desirable to have a String
property that equals a user-defined value, or a localisable string resource if this user-defined value is null or empty. For example, in an application about pets, a user could name their dog "Pepper"
. But, if no name has been provided, then it is feasible to default to a localisable string, "Dog"
via a resource - R.strings.dog_title
.
The issue is that the string resource, R.strings.dog_title
, is of type Int
, whereas the user-defined name is of type String
, and a Text
composable only takes String
or AnnotatedString
as a parameter.
This leads to an awkward choice between missing out on testability by placing logic in the view, or injecting the application context into the viewModel to get the string value from the string resource - both of which I'm wanting to avoid.
My current approach involves passing both values via the uiState
, and then checking the name, before defaulting to the string resource:
// UISTATE
data class ViewUiState (
val string: String? = null,
@StringRes val stringRes: Int,
)
// VIEWMODEL
val uiState = mutableStateOf(ViewUiState(stringRes = 0))
private set
// In the ViewModel, we might get the type of pet currently being shown (e.g. 'Dog')
...
uiState.value = uiState.copy(stringRes = R.string.dog_title)
...
// COMPOSE SCREEN
val uiState by viewModel.uiState
if (!uiState.string.isNullOrEmpty()) {
Text(uiState.string)
} else {
Text(stringResource(uiState.stringResId))
}
The only issue with this approach is that I now miss out on unit testing the null check, as the logic no longer lives in the viewModel.
To keep the logic in the viewModel, it is possible to inject the context and then get the string value using getString(R.strings.dog_title)
. However, its always felt better practice to avoid injecting the context wherever possible.
CodePudding user response:
You can use kotlin sealed class to achieve a better approach, change the uiState type to this class:
sealed class ViewUiState() {
class Default(val stringResId: Int) : ViewUiState()
class UserValue(val string: String) : ViewUiState()
}
the ViewModel code:
val uiState = mutableStateOf<ViewUiState>(ViewUiState.Default(stringRes = 0))
private set
in compose screen:
val uiState by viewModel.uiState
when (uiState) {
is ViewUiState.Default -> {
Text(stringResource(uiState.stringResId))
}
is ViewUiState.UserValue -> {
Text(uiState.string)
}
}
with this code no need to unit testing the null checks
CodePudding user response:
I think that using the following class will be useful for you
You can use kotlin sealed class to achieve a better approach, change the uiState type to this class:
sealed class UiText {
data class DynamicString(val value: String) : UiText()
class StringResource(
@StringRes val resId: Int,
vararg val args: Any
) : UiText()
@Composable
fun asString(): String {
return when (this) {
is DynamicString -> value
is StringResource -> stringResource(resId, *args)
}
}
fun asString(context: Context): String {
return when (this) {
is DynamicString -> value
is StringResource -> context.getString(resId, *args)
}
}
companion object {
fun unknownError(): UiText {
return StringResource(R.string.error_unknown)
}
}
}
A practical example to better understand the solution I provided:
data class ValidationResult(
val successful: Boolean,
val errorMessage: UiText? = null
)
import javax.inject.Inject
class ValidationVolume @Inject constructor() {
operator fun invoke(
volume: Long,
lowestVolume: Long,
highestVolume: Long
): ValidationResult {
if (volume < lowestVolume) {
return ValidationResult(
false,
UiText.StringResource(
R.string.error_volume_can_not_be_lower_than_lowest_volume,
lowestVolume
)
)
}
if (volume > highestVolume) {
return ValidationResult(
false,
UiText.StringResource(
R.string.error_volume_can_not_be_higher_than_highest_volume,
highestVolume
)
)
}
return ValidationResult(true)
}
}
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
class ValidationVolumeTest {
private val lowestVolume = 15000
private val highestVolume = 150000
private val validVolume = 96020
private val lowestInvalidVolume = 120
private val highestInvalidVolume = 200000
private lateinit var validationVolume: ValidationVolume
@Before
fun setUp() {
validationVolume = ValidationVolume()
}
@Test
fun `when price is lower than lowestPrice except error`() {
val result = validationVolume(
volume = lowestInvalidVolume.toLong(),
lowestVolume = lowestVolume.toLong(),
highestVolume = highestVolume.toLong()
)
val actual = (result.errorMessage as UiText.StringResource)
assertThat(result.successful).isFalse()
assertThat(actual.resId).isEqualTo(R.string.error_volume_can_not_be_lower_than_lowest_volume)
assertThat(actual.args.first()).isEqualTo(lowestVolume)
}
@Test
fun `when price is higher than higherPrice except error`() {
val result = validationVolume(
volume = highestInvalidVolume.toLong(),
lowestVolume = lowestVolume.toLong(),
highestVolume = highestVolume.toLong()
)
val actual = (result.errorMessage as UiText.StringResource)
assertThat(result.successful).isFalse()
assertThat(actual.resId).isEqualTo(R.string.error_volume_can_not_be_higher_than_highest_volume)
assertThat(actual.args.first()).isEqualTo(highestVolume)
}
@Test
fun `when price is valid except success`() {
val result = validationVolume(
volume = validVolume.toLong(),
lowestVolume = lowestVolume.toLong(),
highestVolume = highestVolume.toLong()
)
assertThat(result.successful).isTrue()
assertThat(result.errorMessage).isEqualTo(null)
}
}