diff --git a/README.MD b/README.MD index 6695ebb..00ac8d3 100644 --- a/README.MD +++ b/README.MD @@ -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 @@ -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 @@ -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. diff --git a/build.gradle b/build.gradle index e7e7ee7..a9e7ae1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + buildscript { ext { minSdkVersion = 29 @@ -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") + } + } +} diff --git a/core/ui/src/main/kotlin/nl/q42/template/ui/compose/ViewStateStringResolver.kt b/core/ui/src/main/kotlin/nl/q42/template/ui/compose/ViewStateStringResolver.kt new file mode 100644 index 0000000..f5000f6 --- /dev/null +++ b/core/ui/src/main/kotlin/nl/q42/template/ui/compose/ViewStateStringResolver.kt @@ -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 + } +} diff --git a/core/ui/src/main/kotlin/nl/q42/template/ui/presentation/ViewStateString.kt b/core/ui/src/main/kotlin/nl/q42/template/ui/presentation/ViewStateString.kt new file mode 100644 index 0000000..855daeb --- /dev/null +++ b/core/ui/src/main/kotlin/nl/q42/template/ui/presentation/ViewStateString.kt @@ -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 = 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 = 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) diff --git a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt index 4608d5b..26fbf85 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt @@ -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 @@ -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) + ) + } + }, ) } } diff --git a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewState.kt b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewState.kt index 707b6dc..74c4a17 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewState.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewState.kt @@ -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() -} \ No newline at end of file +} diff --git a/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt b/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt index 0d3aba9..adf38cf 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt @@ -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 @@ -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") @@ -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()), {}, {}, {}) } -} \ No newline at end of file +} diff --git a/feature/home/src/main/res/values-nl/strings.xml b/feature/home/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..eae0db0 --- /dev/null +++ b/feature/home/src/main/res/values-nl/strings.xml @@ -0,0 +1,4 @@ + + + Email adres %s + diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml new file mode 100644 index 0000000..91e3eab --- /dev/null +++ b/feature/home/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Email address %s + diff --git a/feature/home/src/test/kotlin/nl/q42/template/presentation/home/HomeViewModelTest.kt b/feature/home/src/test/kotlin/nl/q42/template/presentation/home/HomeViewModelTest.kt index 9bf6438..e512b27 100644 --- a/feature/home/src/test/kotlin/nl/q42/template/presentation/home/HomeViewModelTest.kt +++ b/feature/home/src/test/kotlin/nl/q42/template/presentation/home/HomeViewModelTest.kt @@ -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")) @@ -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) } } - }