Home > other >  Jetpack Compose handling both a string and string resource identifier for a Text Composable
Jetpack Compose handling both a string and string resource identifier for a Text Composable

Time:11-17

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)
    }
}
  • Related