From d780107bc811345222eb2671aa04351cce0582c6 Mon Sep 17 00:00:00 2001 From: Frank1234 Date: Wed, 30 Aug 2023 18:57:20 +0200 Subject: [PATCH 1/5] ADD ViewStateString --- README.MD | 11 ++++- .../ui/compose/ViewStateStringResolver.kt | 31 ++++++++++++++ .../ui/presentation/ViewStateString.kt | 41 +++++++++++++++++++ .../presentation/home/HomeViewModel.kt | 10 ++++- .../presentation/home/HomeViewState.kt | 6 ++- .../nl/q42/template/ui/home/HomeContent.kt | 11 +++-- .../home/src/main/res/values-nl/strings.xml | 4 ++ feature/home/src/main/res/values/strings.xml | 4 ++ 8 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 core/ui/src/main/kotlin/nl/q42/template/ui/compose/ViewStateStringResolver.kt create mode 100644 core/ui/src/main/kotlin/nl/q42/template/ui/presentation/ViewStateString.kt create mode 100644 feature/home/src/main/res/values-nl/strings.xml create mode 100644 feature/home/src/main/res/values/strings.xml diff --git a/README.MD b/README.MD index d07631a..7267457 100644 --- a/README.MD +++ b/README.MD @@ -90,7 +90,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 @@ -161,6 +163,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 enabled 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 @@ -194,7 +201,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/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..197bd7a --- /dev/null +++ b/core/ui/src/main/kotlin/nl/q42/template/ui/compose/ViewStateStringResolver.kt @@ -0,0 +1,31 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package nl.q42.template.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +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.resolve(): 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.resolve() 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..ab11fa8 --- /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 enabled 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: 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(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..bac6483 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.resolve +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.resolve()) + } + 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 + From b283ae081868a755c256253a889e06d9951d825d Mon Sep 17 00:00:00 2001 From: Frank1234 Date: Thu, 28 Sep 2023 14:53:15 +0200 Subject: [PATCH 2/5] CHANGE optic code changes on ViewStateString --- README.MD | 2 +- .../nl/q42/template/ui/presentation/ViewStateString.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.MD b/README.MD index 7267457..93d4860 100644 --- a/README.MD +++ b/README.MD @@ -165,7 +165,7 @@ currently. It can be shared over (included) projects. #### ViewStateString -ViewStateStrings enabled you to move String logic from the View to the ViewModel, especially plurals +ViewStateStrings enables you to move String logic from the View to the ViewModel, especially plurals or replacement parameter logic. ### Compose Previews 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 index ab11fa8..855daeb 100644 --- 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 @@ -4,7 +4,7 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes /** - * This class enabled you to use string logic in the ViewModel, especially plurals or replacement parameter logic. + * 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). * @@ -18,7 +18,7 @@ sealed class ViewStateString { ) : ViewStateString() { // Allow constructing ViewStateString.Res with varargs instead of passing a list @Suppress("unused") - constructor(stringRes: Int, vararg formatArgs: Any) : this(stringRes, formatArgs.toList()) + constructor(@StringRes stringRes: Int, vararg formatArgs: Any) : this(stringRes, formatArgs.toList()) } data class PluralRes( @@ -28,7 +28,7 @@ sealed class ViewStateString { ) : ViewStateString() { // Allow constructing ViewStateString.PluralRes with varargs instead of passing a list @Suppress("unused") - constructor(pluralRes: Int, count: Int, vararg formatArgs: Any) : this( + constructor(@PluralsRes pluralRes: Int, count: Int, vararg formatArgs: Any) : this( pluralRes, count, formatArgs.toList() From 0c385ff9d4ddd0c0c84d25bfbb7deb40bbc6557f Mon Sep 17 00:00:00 2001 From: Frank1234 Date: Thu, 28 Sep 2023 14:56:55 +0200 Subject: [PATCH 3/5] ADD optins in build config --- build.gradle | 15 +++++++++++++++ .../ui/compose/ViewStateStringResolver.kt | 2 -- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 10a2858..f32deb4 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 index 197bd7a..245542f 100644 --- 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 @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - package nl.q42.template.ui.compose import androidx.compose.runtime.Composable From 15e092e5d7a81c23f87d033058b24389d9b968a0 Mon Sep 17 00:00:00 2001 From: Frank1234 Date: Thu, 28 Sep 2023 15:30:08 +0200 Subject: [PATCH 4/5] BUGFIX unit test fix --- .../presentation/home/HomeViewModelTest.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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..d22c731 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,7 +2,6 @@ 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 kotlinx.coroutines.delay @@ -10,18 +9,19 @@ 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,7 +30,7 @@ class HomeViewModelTest(){ val viewModel = HomeViewModel(getUserUseCaseMock, mockk()) viewModel.uiState.test { - val expectedData: HomeViewState = HomeViewState.Data("test@test.com") + val expectedData: HomeViewState = HomeViewState.Data(ViewStateString.Basic("test@test.com")) assertEquals(HomeViewState.Loading, awaitItem()) assertEquals(expectedData, awaitItem()) @@ -38,19 +38,19 @@ class HomeViewModelTest(){ } @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") + val expectedData: HomeViewState = HomeViewState.Data(ViewStateString.Basic("test@test.com")) assertEquals(expectedData, awaitItem()) } } - } From b52554fd00d8d63ecaac8cb12ff3910fd19838a3 Mon Sep 17 00:00:00 2001 From: Frank1234 Date: Mon, 13 Nov 2023 10:37:37 +0100 Subject: [PATCH 5/5] CHANGE get() on viewstring CHANGE fixed unit tests --- .../template/ui/compose/ViewStateStringResolver.kt | 5 ++--- .../kotlin/nl/q42/template/ui/home/HomeContent.kt | 4 ++-- .../template/presentation/home/HomeViewModelTest.kt | 11 +++++++---- 3 files changed, 11 insertions(+), 9 deletions(-) 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 index 245542f..f5000f6 100644 --- 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 @@ -1,7 +1,6 @@ package nl.q42.template.ui.compose import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import nl.q42.template.ui.presentation.ViewStateString @@ -10,12 +9,12 @@ import nl.q42.template.ui.presentation.ViewStateString * Resolve a ViewStateString to a string from a Compose context. */ @Composable -fun ViewStateString.resolve(): String { +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.resolve() else it } + this.formatArgs.map { if (it is ViewStateString) it.get() else it } .toTypedArray() stringResource(id = this.stringRes, formatArgs = resolvedArguments) } 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 bac6483..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,7 +9,7 @@ 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.resolve +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 @@ -34,7 +34,7 @@ internal fun HomeContent( */ when (viewState) { is HomeViewState.Data -> viewState.userEmailTitle?.let { userEmailTitle -> - Text(text = userEmailTitle.resolve()) + Text(text = userEmailTitle.get()) } HomeViewState.Empty -> {} 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 d22c731..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 @@ -4,6 +4,7 @@ import app.cash.turbine.test import io.mockk.coEvery 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 @@ -30,10 +31,11 @@ class HomeViewModelTest() { val viewModel = HomeViewModel(getUserUseCaseMock, mockk()) viewModel.uiState.test { - val expectedData: HomeViewState = HomeViewState.Data(ViewStateString.Basic("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) } } @@ -49,8 +51,9 @@ class HomeViewModelTest() { val viewModel = HomeViewModel(getUserUseCaseMock, mockk()) viewModel.uiState.test { - val expectedData: HomeViewState = HomeViewState.Data(ViewStateString.Basic("test@test.com")) - assertEquals(expectedData, awaitItem()) + val viewState = awaitItem() + assertTrue(viewState is HomeViewState.Data) + assertTrue((viewState as HomeViewState.Data).userEmailTitle is ViewStateString.Res) } } }