Skip to content

Commit

Permalink
Merge pull request #26 from Q42/feature/viewstatestring
Browse files Browse the repository at this point in the history
ADD ViewStateString
  • Loading branch information
Frank1234 authored Nov 13, 2023
2 parents 8a8ac8f + b52554f commit be24dbd
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 22 deletions.
11 changes: 9 additions & 2 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ Model Entities live in the data layer and are the local storage objects.
Models map from XDTO to XEntity to X and then probably into a viewstate. Mapping is always done in
the _outside layer_, before being transported to the inner layer.
See [the diagram](#clean-architecture) for more info.
- Writing mapping functions between models, entities and DTO ojects is boring and cumbersome: let GitHub Copilot or some other AI tool in Android Studio generate your mapping functions.

- Writing mapping functions between models, entities and DTO ojects is boring and cumbersome: let
GitHub Copilot or some other AI tool in Android Studio generate your mapping functions.

#### Core modules

Expand Down Expand Up @@ -179,6 +181,11 @@ modules.
We use a version catalog (toml) file for versioning. This is the latest feature from Gradle
currently. It can be shared over (included) projects.

#### ViewStateString

ViewStateStrings enables you to move String logic from the View to the ViewModel, especially plurals
or replacement parameter logic.

### Compose Previews

Use `@PreviewLightDark` to generate two previews from one function, one dark and one light. You
Expand Down Expand Up @@ -212,7 +219,7 @@ added [extra logic to navigate from ViewModel](https://medium.com/@ffvanderlaan/
- Call `InitNavigator(navigator, viewModel)` from your screen.
- Call `navigateTo(destination)` from your ViewModel to navigate somewhere. There are also popUpTo
methods, etc.

You can call navigateTo with a `AppGraphRoutes` (in core.navigation) to navigate to the root of a
different
graph route.
Expand Down
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

buildscript {
ext {
minSdkVersion = 29
Expand All @@ -13,3 +15,16 @@ plugins { // sets class paths only (because of 'apply false')
alias libs.plugins.hilt apply false
alias libs.plugins.ksp apply false
}

allprojects {
tasks.withType(KotlinCompile).configureEach {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
freeCompilerArgs.add("-opt-in=androidx.compose.foundation.ExperimentalFoundationApi")
freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi")
freeCompilerArgs.add("-opt-in=androidx.compose.material3.ExperimentalMaterial3Api")
freeCompilerArgs.add("-opt-in=androidx.compose.ui.text.ExperimentalTextApi")
freeCompilerArgs.add("-opt-in=androidx.media3.common.util.UnstableApi")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package nl.q42.template.ui.compose

import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import nl.q42.template.ui.presentation.ViewStateString

/**
* Resolve a ViewStateString to a string from a Compose context.
*/
@Composable
fun ViewStateString.get(): String {
return when (this) {
is ViewStateString.Res -> {
// Map any nested ViewStateStrings to their resolved values.
val resolvedArguments =
this.formatArgs.map { if (it is ViewStateString) it.get() else it }
.toTypedArray()
stringResource(id = this.stringRes, formatArgs = resolvedArguments)
}

is ViewStateString.PluralRes -> {
pluralStringResource(id = pluralRes, count = count, count)
}

is ViewStateString.Basic -> this.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package nl.q42.template.ui.presentation

import androidx.annotation.PluralsRes
import androidx.annotation.StringRes

/**
* This class enables you to use string logic in the ViewModel, especially plurals or replacement parameter logic.
*
* All strings will be refreshed on view recreation (i.e. after locale or other config changes).
*
* There is one edge case: the formatArgs are not refreshed on config changes, so if you use a string with a
* replacement parameter that is f.e. a localized date string, move your logic to the view instead of using [ViewStateString].
*/
sealed class ViewStateString {
data class Res(
@StringRes val stringRes: Int,
val formatArgs: List<Any> = listOf()
) : ViewStateString() {
// Allow constructing ViewStateString.Res with varargs instead of passing a list
@Suppress("unused")
constructor(@StringRes stringRes: Int, vararg formatArgs: Any) : this(stringRes, formatArgs.toList())
}

data class PluralRes(
@PluralsRes val pluralRes: Int,
val count: Int,
val formatArgs: List<Any> = listOf(count)
) : ViewStateString() {
// Allow constructing ViewStateString.PluralRes with varargs instead of passing a list
@Suppress("unused")
constructor(@PluralsRes pluralRes: Int, count: Int, vararg formatArgs: Any) : this(
pluralRes,
count,
formatArgs.toList()
)
}

data class Basic(val value: String) : ViewStateString()
}

fun String.toViewStateString(): ViewStateString.Basic = ViewStateString.Basic(this)
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nl.q42.template.actionresult.data.handleAction
import nl.q42.template.domain.user.usecase.GetUserUseCase
import nl.q42.template.feature.home.R
import nl.q42.template.navigation.AppGraphRoutes
import nl.q42.template.navigation.viewmodel.RouteNavigator
import nl.q42.template.ui.home.destinations.HomeSecondScreenDestination
import nl.q42.template.ui.presentation.ViewStateString
import javax.inject.Inject

@HiltViewModel
Expand Down Expand Up @@ -42,7 +44,13 @@ class HomeViewModel @Inject constructor(
handleAction(
getUserUseCase(),
onError = { _uiState.update { HomeViewState.Error } },
onSuccess = { result -> _uiState.update { HomeViewState.Data(result.email) } },
onSuccess = { result ->
_uiState.update {
HomeViewState.Data(
ViewStateString.Res(R.string.emailTitle, result.email)
)
}
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package nl.q42.template.presentation.home

import nl.q42.template.ui.presentation.ViewStateString

sealed class HomeViewState {
data class Data(val userEmail: String? = null) : HomeViewState()
data class Data(val userEmailTitle: ViewStateString? = null) : HomeViewState()
object Loading : HomeViewState()
object Error : HomeViewState()
object Empty : HomeViewState()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import nl.q42.template.presentation.home.HomeViewState
import nl.q42.template.ui.compose.get
import nl.q42.template.ui.presentation.toViewStateString
import nl.q42.template.ui.theme.PreviewAppTheme
import nl.q42.template.ui.theme.PreviewLightDark

Expand All @@ -31,7 +33,10 @@ internal fun HomeContent(
* This is dummy. Use the strings file IRL.
*/
when (viewState) {
is HomeViewState.Data -> Text(text = "Email: ${viewState.userEmail}")
is HomeViewState.Data -> viewState.userEmailTitle?.let { userEmailTitle ->
Text(text = userEmailTitle.get())
}

HomeViewState.Empty -> {}
HomeViewState.Error -> Text(text = "Error")
HomeViewState.Loading -> Text(text = "Loading")
Expand Down Expand Up @@ -78,6 +83,6 @@ private fun HomeContentEmptyPreview() {
@Composable
private fun HomeContentDataPreview() {
PreviewAppTheme {
HomeContent(HomeViewState.Data("preview@preview.com"), {}, {}, {})
HomeContent(HomeViewState.Data("preview@preview.com".toViewStateString()), {}, {}, {})
}
}
}
4 changes: 4 additions & 0 deletions feature/home/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="emailTitle">Email adres %s</string>
</resources>
4 changes: 4 additions & 0 deletions feature/home/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="emailTitle">Email address %s</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,27 @@ package nl.q42.template.presentation.home

import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import nl.q42.template.actionresult.domain.ActionResult
import nl.q42.template.domain.user.model.User
import nl.q42.template.domain.user.usecase.GetUserUseCase
import nl.q42.template.ui.presentation.ViewStateString
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds


class HomeViewModelTest(){
class HomeViewModelTest() {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()

@Test
fun `WHEN I subscribe to uiState with a slow UserUseCase THEN I get the loading state and expected email address`() = runTest{
fun `WHEN I subscribe to uiState with a slow UserUseCase THEN I get the loading state and expected email address`() = runTest {
val getUserUseCaseMock: GetUserUseCase = mockk()
coEvery {getUserUseCaseMock.invoke() }.coAnswers {
coEvery { getUserUseCaseMock.invoke() }.coAnswers {
// demonstration of test scheduler. This does not actually block the test for 4 seconds
delay(4.seconds)
ActionResult.Success(User("test@test.com"))
Expand All @@ -30,27 +31,29 @@ class HomeViewModelTest(){
val viewModel = HomeViewModel(getUserUseCaseMock, mockk())

viewModel.uiState.test {
val expectedData: HomeViewState = HomeViewState.Data("test@test.com")

assertEquals(HomeViewState.Loading, awaitItem())
assertEquals(expectedData, awaitItem())
val viewState = awaitItem()
assertTrue(viewState is HomeViewState.Data)
assertTrue((viewState as HomeViewState.Data).userEmailTitle is ViewStateString.Res)
}
}

@Test
fun `WHEN I subscribe to uiState with a fast UserUseCase THEN I get expected email address immediately`() = runTest{
fun `WHEN I subscribe to uiState with a fast UserUseCase THEN I get expected email address immediately`() = runTest {
val getUserUseCaseMock: GetUserUseCase = mockk()
coEvery { getUserUseCaseMock.invoke() }.returns(
ActionResult.Success(User("test@test.com")
))

ActionResult.Success(
User("test@test.com")
)
)

val viewModel = HomeViewModel(getUserUseCaseMock, mockk())

viewModel.uiState.test {
val expectedData: HomeViewState = HomeViewState.Data("test@test.com")
assertEquals(expectedData, awaitItem())
val viewState = awaitItem()
assertTrue(viewState is HomeViewState.Data)
assertTrue((viewState as HomeViewState.Data).userEmailTitle is ViewStateString.Res)
}
}

}

0 comments on commit be24dbd

Please sign in to comment.