From 3f6c724d712a29bb2e8dc2302b5de3f14785727b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 6 Dec 2022 13:36:19 +0700 Subject: [PATCH 01/23] refactor(base) --- .../debugCheckImmediateMainDispatcher.kt | 17 +++++++++ .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 6 +-- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 6 +-- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 6 +-- .../flowmvi/mvi_base/AbstractMviActivity.kt | 12 ++++-- .../flowmvi/mvi_base/AbstractMviViewModel.kt | 38 ++++++++++++------- 6 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt new file mode 100644 index 00000000..0eaaf571 --- /dev/null +++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt @@ -0,0 +1,17 @@ +package com.hoc.flowmvi.core_ui + +import kotlin.coroutines.ContinuationInterceptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import timber.log.Timber + +suspend fun debugCheckImmediateMainDispatcher() { + if (BuildConfig.DEBUG) { + val interceptor = currentCoroutineContext()[ContinuationInterceptor] + Timber.d("debugCheckImmediateMainDispatcher: interceptor=$interceptor") + + check(interceptor === Dispatchers.Main.immediate) { + "Expected ContinuationInterceptor to be Dispatchers.Main.immediate but was $interceptor" + } + } +} diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 04c43e36..e05a9f80 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -3,7 +3,6 @@ package com.hoc.flowmvi.ui.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import arrow.core.orNull -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel @@ -34,8 +33,9 @@ import timber.log.Timber class AddVM( private val addUser: AddUserUseCase, savedStateHandle: SavedStateHandle, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { +) : AbstractMviViewModel() { + + override val rawLogTag get() = "AddVM[${System.identityHashCode(this)}]" override val viewState: StateFlow diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 343362de..2d612474 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -2,7 +2,6 @@ package com.hoc.flowmvi.ui.main import androidx.lifecycle.viewModelScope import arrow.core.flatMap -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase @@ -37,8 +36,9 @@ class MainVM( private val getUsersUseCase: GetUsersUseCase, private val refreshGetUsers: RefreshGetUsersUseCase, private val removeUser: RemoveUserUseCase, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { +) : AbstractMviViewModel() { + + override val rawLogTag get() = "MainVM[${System.identityHashCode(this)}]" override val viewState: StateFlow diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 3b21094d..7ff69c54 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -2,7 +2,6 @@ package com.hoc.flowmvi.ui.search import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst @@ -37,8 +36,9 @@ import timber.log.Timber class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, private val savedStateHandle: SavedStateHandle, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { +) : AbstractMviViewModel() { + + override val rawLogTag get() = "SearchVM[${System.identityHashCode(this)}]" override val viewState: StateFlow diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt index f2896328..e9fe6c0b 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt @@ -6,13 +6,16 @@ import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.hoc.flowmvi.core_ui.collectIn +import com.hoc.flowmvi.core_ui.debugCheckImmediateMainDispatcher import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -abstract class AbstractMviActivity,>( + VM : MviViewModel, + >( @LayoutRes contentLayoutId: Int, ) : AppCompatActivity(contentLayoutId), MviView { @@ -33,7 +36,10 @@ abstract class AbstractMviActivity( - private val appCoroutineDispatchers: AppCoroutineDispatchers, -) : +abstract class AbstractMviViewModel : MviViewModel, ViewModel() { + protected open val rawLogTag: String? = null + protected val logTag by lazy(PUBLICATION) { - this::class.java.simpleName.let { tag: String -> + (rawLogTag ?: this::class.java.simpleName).let { tag: String -> // Tag length limit was removed in API 26. if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { tag @@ -39,23 +37,35 @@ abstract class AbstractMviViewModel(Channel.UNLIMITED) private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) - final override val singleEvent: Flow get() = eventChannel.receiveAsFlow() + final override val singleEvent: Flow = eventChannel.receiveAsFlow() final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) @CallSuper override fun onCleared() { super.onCleared() eventChannel.close() + Timber.tag(logTag).d("onCleared") } // Send event and access intent flow. + /** + * Must be called in [kotlinx.coroutines.Dispatchers.Main.immediate], + * otherwise it will throw an exception. + * + * If you want to send an event from other [kotlinx.coroutines.CoroutineDispatcher], + * use `withContext(Dispatchers.Main.immediate) { sendEvent(event) }`. + */ protected suspend fun sendEvent(event: E) { - if (currentCoroutineContext()[ContinuationInterceptor] === appCoroutineDispatchers.mainImmediate) { - eventChannel.send(event) - } else { - withContext(appCoroutineDispatchers.mainImmediate) { eventChannel.send(event) } - } + debugCheckImmediateMainDispatcher() + + eventChannel.trySend(event) + .onFailure { + Timber + .tag(logTag) + .e(it, "Failed to send event: $event") + } + .getOrThrow() } protected val intentFlow: SharedFlow get() = intentMutableFlow From 972102b035c8cd44227e63b7983cbd2c3220c3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 6 Dec 2022 14:43:25 +0700 Subject: [PATCH 02/23] refactor(base) --- buildSrc/gradle.properties | 31 ++++++++++++++++++ buildSrc/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61574 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 ++++ .../java/com/hoc/flowmvi/ui/add/AddVMTest.kt | 2 -- .../com/hoc/flowmvi/ui/main/MainVMTest.kt | 2 -- .../com/hoc/flowmvi/ui/search/SearchVMTest.kt | 2 -- settings.gradle.kts | 9 +++++ 7 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 buildSrc/gradle.properties create mode 100644 buildSrc/gradle/wrapper/gradle-wrapper.jar create mode 100644 buildSrc/gradle/wrapper/gradle-wrapper.properties diff --git a/buildSrc/gradle.properties b/buildSrc/gradle.properties new file mode 100644 index 00000000..741e0b2d --- /dev/null +++ b/buildSrc/gradle.properties @@ -0,0 +1,31 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# Enable the Build Cache +org.gradle.caching=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=false + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enable Kotlin incremental compilation +kotlin.incremental=true diff --git a/buildSrc/gradle/wrapper/gradle-wrapper.jar b/buildSrc/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..943f0cbfa754578e88a3dae77fce6e3dea56edbf GIT binary patch literal 61574 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 ScNgau(U?ep-K_E5zy1%ZQTdPn literal 0 HcmV?d00001 diff --git a/buildSrc/gradle/wrapper/gradle-wrapper.properties b/buildSrc/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2b22d057 --- /dev/null +++ b/buildSrc/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt index 0f03a327..89c8c720 100644 --- a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt +++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt @@ -12,7 +12,6 @@ import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_testing.BaseMviViewModelTest import com.hoc.flowmvi.mvi_testing.mapRight import com.hoc.flowmvi.mvi_testing.returnsWithDelay -import com.hoc.flowmvi.test_utils.TestAppCoroutineDispatchers import com.hoc.flowmvi.test_utils.valueOrThrow import io.mockk.coEvery import io.mockk.coVerify @@ -43,7 +42,6 @@ class AddVMTest : BaseMviViewModelTest + rootDir.resolve(sourcePath).copyRecursively( + target = rootDir.resolve("buildSrc").resolve(sourcePath), + overwrite = true + ) + println("[DONE] copied $sourcePath") +} +arrayOf("gradle.properties", "gradle").forEach(copyToBuildSrc) + include(":app") include(":feature-main") include(":feature-add") From 05d52304c8e8c0e89d084bd6fa2f7cefe8e67284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 6 Dec 2022 17:19:12 +0700 Subject: [PATCH 03/23] refactor(main): MainVM --- .../com/hoc/flowmvi/ui/main/MainActivity.kt | 2 +- .../com/hoc/flowmvi/ui/main/MainContract.kt | 36 ++-- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 155 +++++++++--------- 3 files changed, 99 insertions(+), 94 deletions(-) diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt index 9853b88c..fe7f9c84 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt @@ -130,7 +130,7 @@ class MainActivity : swipeRefreshLayout.isRefreshing = false } - swipeRefreshLayout.isEnabled = !viewState.isLoading && viewState.error === null + swipeRefreshLayout.isEnabled = viewState.canRefresh } } } diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt index 42d734fa..51a871b5 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt @@ -46,6 +46,8 @@ data class ViewState( val error: UserError?, val isRefreshing: Boolean ) : MviViewState { + inline val canRefresh get() = !isLoading && error === null + companion object { fun initial() = ViewState( userItems = emptyList(), @@ -56,39 +58,39 @@ data class ViewState( } } -internal sealed interface PartialChange { - fun reduce(vs: ViewState): ViewState +internal sealed interface PartialStateChange { + fun reduce(viewState: ViewState): ViewState - sealed class GetUser : PartialChange { - override fun reduce(vs: ViewState): ViewState { + sealed class Users : PartialStateChange { + override fun reduce(viewState: ViewState): ViewState { return when (this) { - Loading -> vs.copy( + Loading -> viewState.copy( isLoading = true, error = null ) - is Data -> vs.copy( + is Data -> viewState.copy( isLoading = false, error = null, userItems = users ) - is Error -> vs.copy( + is Error -> viewState.copy( isLoading = false, error = error ) } } - object Loading : GetUser() - data class Data(val users: List) : GetUser() - data class Error(val error: UserError) : GetUser() + object Loading : Users() + data class Data(val users: List) : Users() + data class Error(val error: UserError) : Users() } - sealed class Refresh : PartialChange { - override fun reduce(vs: ViewState): ViewState { + sealed class Refresh : PartialStateChange { + override fun reduce(viewState: ViewState): ViewState { return when (this) { - is Success -> vs.copy(isRefreshing = false) - is Failure -> vs.copy(isRefreshing = false) - Loading -> vs.copy(isRefreshing = true) + is Success -> viewState.copy(isRefreshing = false) + is Failure -> viewState.copy(isRefreshing = false) + Loading -> viewState.copy(isRefreshing = true) } } @@ -97,11 +99,11 @@ internal sealed interface PartialChange { data class Failure(val error: UserError) : Refresh() } - sealed class RemoveUser : PartialChange { + sealed class RemoveUser : PartialStateChange { data class Success(val user: UserItem) : RemoveUser() data class Failure(val user: UserItem, val error: UserError) : RemoveUser() - override fun reduce(vs: ViewState) = vs + override fun reduce(viewState: ViewState) = viewState } } diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 2d612474..240c2644 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -13,14 +13,13 @@ import com.hoc081098.flowext.startWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -49,10 +48,12 @@ class MainVM( intentFlow.filterIsInstance().take(1), intentFlow.filterNot { it is ViewIntent.Initial } ) - .toPartialChangeFlow() - .sendSingleEvent() + .shareWhileSubscribed() + .toPartialStateChangeFlow() + .log("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { vs, change -> change.reduce(vs) } - .catch { Timber.tag(logTag).e(it, "[MAIN_VM] Throwable: $it") } + .log("ViewState") .stateIn( viewModelScope, SharingStarted.Eagerly, @@ -60,81 +61,83 @@ class MainVM( ) } - private fun Flow.sendSingleEvent(): Flow { - return onEach { change -> - val event = when (change) { - is PartialChange.GetUser.Error -> SingleEvent.GetUsersError(change.error) - is PartialChange.Refresh.Success -> SingleEvent.Refresh.Success - is PartialChange.Refresh.Failure -> SingleEvent.Refresh.Failure(change.error) - is PartialChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(change.user) - is PartialChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( - user = change.user, - error = change.error, - indexProducer = { - viewState.value - .userItems - .indexOfFirst { it.id == change.user.id } - .takeIf { it != -1 } - } + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.Users.Error -> SingleEvent.GetUsersError(error) + is PartialStateChange.Refresh.Success -> SingleEvent.Refresh.Success + is PartialStateChange.Refresh.Failure -> SingleEvent.Refresh.Failure(error) + is PartialStateChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(user) + is PartialStateChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( + user = user, + error = error, + indexProducer = { + viewState.value + .userItems + .indexOfFirst { it.id == user.id } + .takeIf { it != -1 } + } + ) + PartialStateChange.Users.Loading, + is PartialStateChange.Users.Data, + PartialStateChange.Refresh.Loading, + -> null + } + + private fun SharedFlow.toPartialStateChangeFlow(): Flow = run { + val userChanges = defer(getUsersUseCase::invoke) + .onEach { either -> Timber.tag(logTag).d("Emit users.size=${either.map { it.size }}") } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Users.Error(it) }, + ifRight = { PartialStateChange.Users.Data(it.map(::UserItem)) } ) - PartialChange.GetUser.Loading -> return@onEach - is PartialChange.GetUser.Data -> return@onEach - PartialChange.Refresh.Loading -> return@onEach } - sendEvent(event) - } + .startWith(PartialStateChange.Users.Loading) + + return merge( + // users change + merge( + filterIsInstance(), + filterIsInstance() + .filter { viewState.value.error != null }, + ).flatMapLatest { userChanges }, + // refresh change + filterIsInstance() + .toRefreshChangeFlow(), + // remove user change + filterIsInstance() + .toRemoveUserChangeFlow() + ) } - private fun Flow.toPartialChangeFlow(): Flow = - shareWhileSubscribed().run { - val getUserChanges = defer(getUsersUseCase::invoke) - .onEach { either -> Timber.d("[MAIN_VM] Emit users.size=${either.map { it.size }}") } - .map { result -> - result.fold( - ifLeft = { PartialChange.GetUser.Error(it) }, - ifRight = { PartialChange.GetUser.Data(it.map(::UserItem)) } - ) - } - .startWith(PartialChange.GetUser.Loading) + //region Processors + private fun Flow.toRefreshChangeFlow(): Flow { + val refreshChanges = flowFromSuspend(refreshGetUsers::invoke) + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Refresh.Failure(it) }, + ifRight = { PartialStateChange.Refresh.Success } + ) + } + .startWith(PartialStateChange.Refresh.Loading) - val refreshChanges = refreshGetUsers::invoke - .asFlow() - .map { result -> - result.fold( - ifLeft = { PartialChange.Refresh.Failure(it) }, - ifRight = { PartialChange.Refresh.Success } - ) - } - .startWith(PartialChange.Refresh.Loading) + return filter { viewState.value.canRefresh } + .flatMapFirst { refreshChanges } + } - return merge( - filterIsInstance() - .log("Intent") - .flatMapConcat { getUserChanges }, - filterIsInstance() - .filter { viewState.value.let { !it.isLoading && it.error === null } } - .log("Intent") - .flatMapFirst { refreshChanges }, - filterIsInstance() - .filter { viewState.value.error != null } - .log("Intent") - .flatMapFirst { getUserChanges }, - filterIsInstance() - .log("Intent") - .map { it.user } - .flatMapMerge { userItem -> - flowFromSuspend { - userItem - .toDomain() - .flatMap { removeUser(it) } - } - .map { result -> - result.fold( - ifLeft = { PartialChange.RemoveUser.Failure(userItem, it) }, - ifRight = { PartialChange.RemoveUser.Success(userItem) }, - ) - } + private fun Flow.toRemoveUserChangeFlow(): Flow = + map { it.user } + .flatMapMerge { userItem -> + flowFromSuspend { + userItem + .toDomain() + .flatMap { removeUser(it) } + } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.RemoveUser.Failure(userItem, it) }, + ifRight = { PartialStateChange.RemoveUser.Success(userItem) }, + ) } - ) - } + } + //endregion } From 03d024cdd802d43e0b47f426815bd0d243ccfd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 6 Dec 2022 17:21:30 +0700 Subject: [PATCH 04/23] refactor(main): MainVM --- feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 240c2644..5971f809 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -82,7 +82,7 @@ class MainVM( -> null } - private fun SharedFlow.toPartialStateChangeFlow(): Flow = run { + private fun SharedFlow.toPartialStateChangeFlow(): Flow { val userChanges = defer(getUsersUseCase::invoke) .onEach { either -> Timber.tag(logTag).d("Emit users.size=${either.map { it.size }}") } .map { result -> From d10ad5caf5b94c3bb47ac19805d2fe35a957e797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 6 Dec 2022 17:26:42 +0700 Subject: [PATCH 05/23] refactor(base) --- .../src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 14 +++++++------- .../main/java/com/hoc/flowmvi/ui/main/MainVM.kt | 4 ++-- .../java/com/hoc/flowmvi/ui/search/SearchVM.kt | 2 +- .../hoc/flowmvi/mvi_base/AbstractMviViewModel.kt | 8 ++++++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index e05a9f80..2dd7101c 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -76,19 +76,19 @@ class AddVM( private fun SharedFlow.toPartialStateChangesFlow(): Flow { val emailFlow = filterIsInstance() - .log("Intent") + .debugLog("Intent") .map { it.email } .distinctUntilChanged() .shareWhileSubscribed() val firstNameFlow = filterIsInstance() - .log("Intent") + .debugLog("Intent") .map { it.firstName } .distinctUntilChanged() .shareWhileSubscribed() val lastNameFlow = filterIsInstance() - .log("Intent") + .debugLog("Intent") .map { it.lastName } .distinctUntilChanged() .shareWhileSubscribed() @@ -108,7 +108,7 @@ class AddVM( }.stateWithInitialNullWhileSubscribed() val addUserChanges = filterIsInstance() - .log("Intent") + .debugLog("Intent") .withLatestFrom(userFormFlow) { _, userForm -> userForm } .mapNotNull { it?.orNull() } .flatMapFirst { user -> @@ -124,15 +124,15 @@ class AddVM( val firstChanges = merge( filterIsInstance() - .log("Intent") + .debugLog("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), filterIsInstance() - .log("Intent") + .debugLog("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), filterIsInstance() - .log("Intent") + .debugLog("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) ) diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 5971f809..24e13e62 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -50,10 +50,10 @@ class MainVM( ) .shareWhileSubscribed() .toPartialStateChangeFlow() - .log("PartialStateChange") + .debugLog("PartialStateChange") .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { vs, change -> change.reduce(vs) } - .log("ViewState") + .debugLog("ViewState") .stateIn( viewModelScope, SharingStarted.Eagerly, diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 7ff69c54..f72dcaac 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -73,7 +73,7 @@ class SearchVM( } val queryFlow = filterIsInstance() - .log("Intent") + .debugLog("Intent") .map { it.query } .shareWhileSubscribed() diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt index b1c71095..2caad727 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt @@ -72,8 +72,12 @@ abstract class AbstractMviViewModel Flow.log(subject: String): Flow = - onEach { Timber.tag(logTag).d(">>> $subject: $it") } + protected fun Flow.debugLog(subject: String): Flow = + if (BuildConfig.DEBUG) { + onEach { Timber.tag(logTag).d(">>> $subject: $it") } + } else { + this + } protected fun Flow.shareWhileSubscribed(): SharedFlow = shareIn(viewModelScope, SharingStarted.WhileSubscribed()) From d8dc9d84ac0303846fc9ba303870be99227ee527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Wed, 7 Dec 2022 14:11:50 +0700 Subject: [PATCH 06/23] refactor(base): sealed interface --- .../com/hoc/flowmvi/ui/add/AddContract.kt | 39 ++++++++++++------- .../com/hoc/flowmvi/ui/main/MainContract.kt | 22 +++++------ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index 4aee6710..39d61db4 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -56,10 +56,10 @@ internal sealed interface PartialStateChange { if (viewState.errors == errors) viewState else viewState.copy(errors = errors) } - sealed class AddUser : PartialStateChange { - object Loading : AddUser() - data class AddUserSuccess(val user: User) : AddUser() - data class AddUserFailure(val user: User, val error: UserError) : AddUser() + sealed interface AddUser : PartialStateChange { + object Loading : AddUser + data class AddUserSuccess(val user: User) : AddUser + data class AddUserFailure(val user: User, val error: UserError) : AddUser override fun reduce(viewState: ViewState): ViewState { return when (this) { @@ -70,21 +70,30 @@ internal sealed interface PartialStateChange { } } - sealed class FirstChange : PartialStateChange { - object EmailChangedFirstTime : FirstChange() - object FirstNameChangedFirstTime : FirstChange() - object LastNameChangedFirstTime : FirstChange() + sealed interface FirstChange : PartialStateChange { + object EmailChangedFirstTime : FirstChange + object FirstNameChangedFirstTime : FirstChange + object LastNameChangedFirstTime : FirstChange override fun reduce(viewState: ViewState): ViewState { return when (this) { - EmailChangedFirstTime -> viewState.copy(emailChanged = true) - FirstNameChangedFirstTime -> viewState.copy(firstNameChanged = true) - LastNameChangedFirstTime -> viewState.copy(lastNameChanged = true) + EmailChangedFirstTime -> { + if (viewState.emailChanged) viewState + else viewState.copy(emailChanged = true) + } + FirstNameChangedFirstTime -> { + if (viewState.firstNameChanged) viewState + else viewState.copy(firstNameChanged = true) + } + LastNameChangedFirstTime -> { + if (viewState.lastNameChanged) viewState + else viewState.copy(lastNameChanged = true) + } } } } - sealed class FormValueChange : PartialStateChange { + sealed interface FormValueChange : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { is EmailChanged -> { @@ -102,9 +111,9 @@ internal sealed interface PartialStateChange { } } - data class EmailChanged(val email: String?) : FormValueChange() - data class FirstNameChanged(val firstName: String?) : FormValueChange() - data class LastNameChanged(val lastName: String?) : FormValueChange() + data class EmailChanged(val email: String?) : FormValueChange + data class FirstNameChanged(val firstName: String?) : FormValueChange + data class LastNameChanged(val lastName: String?) : FormValueChange } } diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt index 51a871b5..aeec2ee8 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt @@ -61,7 +61,7 @@ data class ViewState( internal sealed interface PartialStateChange { fun reduce(viewState: ViewState): ViewState - sealed class Users : PartialStateChange { + sealed interface Users : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { Loading -> viewState.copy( @@ -80,12 +80,12 @@ internal sealed interface PartialStateChange { } } - object Loading : Users() - data class Data(val users: List) : Users() - data class Error(val error: UserError) : Users() + object Loading : Users + data class Data(val users: List) : Users + data class Error(val error: UserError) : Users } - sealed class Refresh : PartialStateChange { + sealed interface Refresh : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { is Success -> viewState.copy(isRefreshing = false) @@ -94,14 +94,14 @@ internal sealed interface PartialStateChange { } } - object Loading : Refresh() - object Success : Refresh() - data class Failure(val error: UserError) : Refresh() + object Loading : Refresh + object Success : Refresh + data class Failure(val error: UserError) : Refresh } - sealed class RemoveUser : PartialStateChange { - data class Success(val user: UserItem) : RemoveUser() - data class Failure(val user: UserItem, val error: UserError) : RemoveUser() + sealed interface RemoveUser : PartialStateChange { + data class Success(val user: UserItem) : RemoveUser + data class Failure(val user: UserItem, val error: UserError) : RemoveUser override fun reduce(viewState: ViewState) = viewState } From 4c9b8f0cafea516cc506090c11481ce641da2842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Wed, 7 Dec 2022 16:14:11 +0700 Subject: [PATCH 07/23] refactor(add): viewmodel --- .../com/hoc/flowmvi/ui/add/AddContract.kt | 10 +- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 134 +++++++++--------- .../com/hoc/flowmvi/ui/main/MainContract.kt | 5 +- 3 files changed, 79 insertions(+), 70 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index 39d61db4..16608a63 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -51,7 +51,7 @@ sealed interface ViewIntent : MviIntent { internal sealed interface PartialStateChange { fun reduce(viewState: ViewState): ViewState - data class ErrorsChanged(val errors: Set) : PartialStateChange { + data class Errors(val errors: Set) : PartialStateChange { override fun reduce(viewState: ViewState) = if (viewState.errors == errors) viewState else viewState.copy(errors = errors) } @@ -93,7 +93,7 @@ internal sealed interface PartialStateChange { } } - sealed interface FormValueChange : PartialStateChange { + sealed interface FormValue : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { is EmailChanged -> { @@ -111,9 +111,9 @@ internal sealed interface PartialStateChange { } } - data class EmailChanged(val email: String?) : FormValueChange - data class FirstNameChanged(val firstName: String?) : FormValueChange - data class LastNameChanged(val lastName: String?) : FormValueChange + data class EmailChanged(val email: String?) : FormValue + data class FirstNameChanged(val firstName: String?) : FormValue + data class LastNameChanged(val lastName: String?) : FormValue } } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 2dd7101c..b4b708ea 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -2,8 +2,10 @@ package com.hoc.flowmvi.ui.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import arrow.core.ValidatedNel import arrow.core.orNull import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst @@ -16,7 +18,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance @@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import timber.log.Timber +private typealias UserFormStateFlow = StateFlow?> + @ExperimentalCoroutinesApi class AddVM( private val addUser: AddUserUseCase, @@ -40,56 +43,54 @@ class AddVM( override val viewState: StateFlow init { - val initialVS = savedStateHandle.get(VIEW_STATE)?.copy(isLoading = false) + val initialVS = savedStateHandle.get(VIEW_STATE) + ?.copy(isLoading = false) ?: ViewState.initial() Timber.tag(logTag).d("[ADD_VM] initialVS: $initialVS") viewState = intentFlow - .toPartialStateChangesFlow() - .sendSingleEvent() + .toPartialStateChangeFlow(initialVS) + .debugLog("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } .onEach { savedStateHandle[VIEW_STATE] = it } - .catch { Timber.tag(logTag).e(it, "[ADD_VM] Throwable: $it") } + .debugLog("ViewState") .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } - private fun Flow.sendSingleEvent(): Flow { - return onEach { change -> - val event = when (change) { - is PartialStateChange.ErrorsChanged -> return@onEach - PartialStateChange.AddUser.Loading -> return@onEach - is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(change.user) - is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( - change.user, - change.error - ) - PartialStateChange.FirstChange.EmailChangedFirstTime -> return@onEach - PartialStateChange.FirstChange.FirstNameChangedFirstTime -> return@onEach - PartialStateChange.FirstChange.LastNameChangedFirstTime -> return@onEach - is PartialStateChange.FormValueChange.EmailChanged -> return@onEach - is PartialStateChange.FormValueChange.FirstNameChanged -> return@onEach - is PartialStateChange.FormValueChange.LastNameChanged -> return@onEach - } - sendEvent(event) - } + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(user) + is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( + user = user, + error = error + ) + PartialStateChange.FirstChange.EmailChangedFirstTime, + PartialStateChange.FirstChange.FirstNameChangedFirstTime, + PartialStateChange.FirstChange.LastNameChangedFirstTime, + is PartialStateChange.FormValue.EmailChanged, + is PartialStateChange.FormValue.FirstNameChanged, + is PartialStateChange.FormValue.LastNameChanged, + is PartialStateChange.Errors, + PartialStateChange.AddUser.Loading, + -> null } - private fun SharedFlow.toPartialStateChangesFlow(): Flow { + private fun SharedFlow.toPartialStateChangeFlow(initialVS: ViewState): Flow { val emailFlow = filterIsInstance() - .debugLog("Intent") .map { it.email } + .startWith(initialVS.email) .distinctUntilChanged() .shareWhileSubscribed() val firstNameFlow = filterIsInstance() - .debugLog("Intent") .map { it.firstName } + .startWith(initialVS.firstName) .distinctUntilChanged() .shareWhileSubscribed() val lastNameFlow = filterIsInstance() - .debugLog("Intent") .map { it.lastName } + .startWith(initialVS.lastName) .distinctUntilChanged() .shareWhileSubscribed() @@ -107,9 +108,41 @@ class AddVM( ) }.stateWithInitialNullWhileSubscribed() - val addUserChanges = filterIsInstance() - .debugLog("Intent") - .withLatestFrom(userFormFlow) { _, userForm -> userForm } + val formValuesChangeFlow = merge( + emailFlow.map { PartialStateChange.FormValue.EmailChanged(it) }, + firstNameFlow.map { PartialStateChange.FormValue.FirstNameChanged(it) }, + lastNameFlow.map { PartialStateChange.FormValue.LastNameChanged(it) }, + ) + + return merge( + // form values change + formValuesChangeFlow, + // first change + toFirstChangeFlow(), + // errors change + userFormFlow.toErrorsChangeFlow(), + // add user change + filterIsInstance() + .toAddUserChangeFlow(userFormFlow), + ) + } + + //region Processors + private fun SharedFlow.toFirstChangeFlow(): Flow = + merge( + filterIsInstance() + .take(1) + .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), + filterIsInstance() + .take(1) + .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), + filterIsInstance() + .take(1) + .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) + ) + + private fun Flow.toAddUserChangeFlow(userFormFlow: UserFormStateFlow): Flow = + withLatestFrom(userFormFlow) { _, userForm -> userForm } .mapNotNull { it?.orNull() } .flatMapFirst { user -> flowFromSuspend { addUser(user) } @@ -122,43 +155,16 @@ class AddVM( .startWith(PartialStateChange.AddUser.Loading) } - val firstChanges = merge( - filterIsInstance() - .debugLog("Intent") - .take(1) - .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), - filterIsInstance() - .debugLog("Intent") - .take(1) - .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), - filterIsInstance() - .debugLog("Intent") - .take(1) - .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) - ) - - val formValuesChanges = merge( - emailFlow.map { PartialStateChange.FormValueChange.EmailChanged(it) }, - firstNameFlow.map { PartialStateChange.FormValueChange.FirstNameChanged(it) }, - lastNameFlow.map { PartialStateChange.FormValueChange.LastNameChanged(it) }, - ) - - val errorsChanges = userFormFlow.map { validated -> - PartialStateChange.ErrorsChanged( + private fun UserFormStateFlow.toErrorsChangeFlow(): Flow = + map { validated -> + PartialStateChange.Errors( validated?.fold( - { it.toSet() }, - { emptySet() } + fe = { it.toSet() }, + fa = { emptySet() } ) ?: emptySet() ) } - - return merge( - formValuesChanges, - errorsChanges, - addUserChanges, - firstChanges, - ) - } + //endregion private companion object { private const val VIEW_STATE = "com.hoc.flowmvi.ui.add.view_state" diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt index aeec2ee8..483a2c8e 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt @@ -1,11 +1,13 @@ package com.hoc.flowmvi.ui.main +import androidx.annotation.MainThread import arrow.core.Either import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState +import kotlin.LazyThreadSafetyMode.NONE data class UserItem( val id: String, @@ -14,7 +16,8 @@ data class UserItem( val firstName: String, val lastName: String ) { - val fullName get() = "$firstName $lastName" + @get:MainThread + val fullName by lazy(NONE) { "$firstName $lastName" } constructor(domain: User) : this( id = domain.id, From c95f111633ea18ad40908f3505c44612e3a42c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Wed, 7 Dec 2022 16:25:01 +0700 Subject: [PATCH 08/23] refactor(add): fix viewmodel --- feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index b4b708ea..111ae107 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -130,13 +130,13 @@ class AddVM( //region Processors private fun SharedFlow.toFirstChangeFlow(): Flow = merge( - filterIsInstance() + filterIsInstance() .take(1) .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), - filterIsInstance() + filterIsInstance() .take(1) .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), - filterIsInstance() + filterIsInstance() .take(1) .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) ) From 45f0728613c835250945ab7cb0f758c2525534e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 00:58:01 +0700 Subject: [PATCH 09/23] refactor(add): vm --- .../domain/model/UserValidationError.kt | 10 +++ .../com/hoc/flowmvi/ui/add/AddActivity.kt | 6 +- .../com/hoc/flowmvi/ui/add/AddContract.kt | 62 ++++++++----------- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 59 ++++++------------ .../flowmvi/mvi_base/AbstractMviViewModel.kt | 10 +-- 5 files changed, 63 insertions(+), 84 deletions(-) diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt index b7daa835..56ed18d7 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt @@ -9,4 +9,14 @@ enum class UserValidationError { TOO_SHORT_LAST_NAME; val asInvalidNel: ValidatedNel = invalidNel() + + companion object { + /** + * Use this instead of [values()] for more performant. + * See [KT-48872](https://youtrack.jetbrains.com/issue/KT-48872) + */ + val VALUES: List = values().asList() + + val VALUES_SET: Set = VALUES.toSet() + } } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt index eb794d64..166c1ad5 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt @@ -96,15 +96,15 @@ class AddActivity : emailEditText .editText!! .textChanges() - .map { ViewIntent.EmailChanged(it?.toString()) }, + .map { ViewIntent.EmailChanged(it?.toString().orEmpty()) }, firstNameEditText .editText!! .textChanges() - .map { ViewIntent.FirstNameChanged(it?.toString()) }, + .map { ViewIntent.FirstNameChanged(it?.toString().orEmpty()) }, lastNameEditText .editText!! .textChanges() - .map { ViewIntent.LastNameChanged(it?.toString()) }, + .map { ViewIntent.LastNameChanged(it?.toString().orEmpty()) }, addButton .clicks() .map { ViewIntent.Submit }, diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index 16608a63..bf966f08 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -1,6 +1,7 @@ package com.hoc.flowmvi.ui.add import android.os.Parcelable +import arrow.core.ValidatedNel import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.model.UserValidationError @@ -18,28 +19,28 @@ data class ViewState( val firstNameChanged: Boolean, val lastNameChanged: Boolean, // form values - val email: String?, - val firstName: String?, - val lastName: String?, + val email: String, + val firstName: String, + val lastName: String, ) : MviViewState, Parcelable { companion object { fun initial() = ViewState( - errors = emptySet(), + errors = UserValidationError.VALUES_SET, isLoading = false, emailChanged = false, firstNameChanged = false, lastNameChanged = false, - email = null, - firstName = null, - lastName = null, + email = "", + firstName = "", + lastName = "", ) } } sealed interface ViewIntent : MviIntent { - data class EmailChanged(val email: String?) : ViewIntent - data class FirstNameChanged(val firstName: String?) : ViewIntent - data class LastNameChanged(val lastName: String?) : ViewIntent + data class EmailChanged(val email: String) : ViewIntent + data class FirstNameChanged(val firstName: String) : ViewIntent + data class LastNameChanged(val lastName: String) : ViewIntent object Submit : ViewIntent @@ -51,9 +52,21 @@ sealed interface ViewIntent : MviIntent { internal sealed interface PartialStateChange { fun reduce(viewState: ViewState): ViewState - data class Errors(val errors: Set) : PartialStateChange { - override fun reduce(viewState: ViewState) = - if (viewState.errors == errors) viewState else viewState.copy(errors = errors) + data class UserFormState( + val email: String, + val firstName: String, + val lastName: String, + val userValidatedNel: ValidatedNel, + ) : PartialStateChange { + override fun reduce(viewState: ViewState): ViewState = viewState.copy( + email = email, + firstName = firstName, + lastName = lastName, + errors = userValidatedNel.fold( + fe = { it.toSet() }, + fa = { emptySet() }, + ), + ) } sealed interface AddUser : PartialStateChange { @@ -92,29 +105,6 @@ internal sealed interface PartialStateChange { } } } - - sealed interface FormValue : PartialStateChange { - override fun reduce(viewState: ViewState): ViewState { - return when (this) { - is EmailChanged -> { - if (viewState.email == email) viewState - else viewState.copy(email = email) - } - is FirstNameChanged -> { - if (viewState.firstName == firstName) viewState - else viewState.copy(firstName = firstName) - } - is LastNameChanged -> { - if (viewState.lastName == lastName) viewState - else viewState.copy(lastName = lastName) - } - } - } - - data class EmailChanged(val email: String?) : FormValue - data class FirstNameChanged(val firstName: String?) : FormValue - data class LastNameChanged(val lastName: String?) : FormValue - } } sealed interface SingleEvent : MviSingleEvent { diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 111ae107..158480de 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -2,10 +2,8 @@ package com.hoc.flowmvi.ui.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import arrow.core.ValidatedNel import arrow.core.orNull import com.hoc.flowmvi.domain.model.User -import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst @@ -30,8 +28,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import timber.log.Timber -private typealias UserFormStateFlow = StateFlow?> - @ExperimentalCoroutinesApi class AddVM( private val addUser: AddUserUseCase, @@ -53,6 +49,7 @@ class AddVM( .debugLog("PartialStateChange") .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } + .distinctUntilChanged() .onEach { savedStateHandle[VIEW_STATE] = it } .debugLog("ViewState") .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) @@ -67,10 +64,7 @@ class AddVM( PartialStateChange.FirstChange.EmailChangedFirstTime, PartialStateChange.FirstChange.FirstNameChangedFirstTime, PartialStateChange.FirstChange.LastNameChangedFirstTime, - is PartialStateChange.FormValue.EmailChanged, - is PartialStateChange.FormValue.FirstNameChanged, - is PartialStateChange.FormValue.LastNameChanged, - is PartialStateChange.Errors, + is PartialStateChange.UserFormState, PartialStateChange.AddUser.Loading, -> null } @@ -80,50 +74,44 @@ class AddVM( .map { it.email } .startWith(initialVS.email) .distinctUntilChanged() - .shareWhileSubscribed() val firstNameFlow = filterIsInstance() .map { it.firstName } .startWith(initialVS.firstName) .distinctUntilChanged() - .shareWhileSubscribed() val lastNameFlow = filterIsInstance() .map { it.lastName } .startWith(initialVS.lastName) .distinctUntilChanged() - .shareWhileSubscribed() - val userFormFlow = combine( + val userFormStateFlow = combine( emailFlow, firstNameFlow, lastNameFlow, ) { email, firstName, lastName -> - User.create( + PartialStateChange.UserFormState( email = email, firstName = firstName, lastName = lastName, - id = "", - avatar = "", + userValidatedNel = User.create( + email = email, + firstName = firstName, + lastName = lastName, + id = "", + avatar = "", + ), ) - }.stateWithInitialNullWhileSubscribed() - - val formValuesChangeFlow = merge( - emailFlow.map { PartialStateChange.FormValue.EmailChanged(it) }, - firstNameFlow.map { PartialStateChange.FormValue.FirstNameChanged(it) }, - lastNameFlow.map { PartialStateChange.FormValue.LastNameChanged(it) }, - ) + }.shareWhileSubscribed() return merge( - // form values change - formValuesChangeFlow, + // user form state change + userFormStateFlow, // first change toFirstChangeFlow(), - // errors change - userFormFlow.toErrorsChangeFlow(), // add user change filterIsInstance() - .toAddUserChangeFlow(userFormFlow), + .toAddUserChangeFlow(userFormStateFlow), ) } @@ -141,9 +129,10 @@ class AddVM( .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) ) - private fun Flow.toAddUserChangeFlow(userFormFlow: UserFormStateFlow): Flow = - withLatestFrom(userFormFlow) { _, userForm -> userForm } - .mapNotNull { it?.orNull() } + private fun Flow.toAddUserChangeFlow(userFormFlow: SharedFlow): Flow = + withLatestFrom(userFormFlow) { _, userForm -> userForm.userValidatedNel } + .debugLog("toAddUserChangeFlow::userValidatedNel") + .mapNotNull { it.orNull() } .flatMapFirst { user -> flowFromSuspend { addUser(user) } .map { result -> @@ -154,16 +143,6 @@ class AddVM( } .startWith(PartialStateChange.AddUser.Loading) } - - private fun UserFormStateFlow.toErrorsChangeFlow(): Flow = - map { validated -> - PartialStateChange.Errors( - validated?.fold( - fe = { it.toSet() }, - fa = { emptySet() } - ) ?: emptySet() - ) - } //endregion private companion object { diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt index 2caad727..00692d50 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt @@ -12,11 +12,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn import timber.log.Timber abstract class AbstractMviViewModel : @@ -79,12 +77,14 @@ abstract class AbstractMviViewModel Flow.shareWhileSubscribed(): SharedFlow = shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - protected fun Flow.stateWithInitialNullWhileSubscribed(): StateFlow = - stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - private companion object { /** * The buffer size that will be allocated by [kotlinx.coroutines.flow.MutableSharedFlow]. From dafd61ac8279bc62cfda74efb80dd72327207a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 01:03:24 +0700 Subject: [PATCH 10/23] refactor(add): fix tests --- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 1 - .../java/com/hoc/flowmvi/ui/add/AddVMTest.kt | 137 ++---------------- 2 files changed, 13 insertions(+), 125 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 158480de..5198cc96 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -49,7 +49,6 @@ class AddVM( .debugLog("PartialStateChange") .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } - .distinctUntilChanged() .onEach { savedStateHandle[VIEW_STATE] = it } .debugLog("ViewState") .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt index 89c8c720..2effb0ba 100644 --- a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt +++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt @@ -22,7 +22,7 @@ import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf -private val ALL_ERRORS = UserValidationError.values().toSet() +private val ALL_ERRORS = UserValidationError.VALUES_SET private const val EMAIL = "hoc081098@gmail.com" private const val NAME = "hoc081098" @@ -68,47 +68,6 @@ class AddVMTest : BaseMviViewModelTest Date: Thu, 8 Dec 2022 10:59:38 +0700 Subject: [PATCH 11/23] refactor(main): MainVM --- build.gradle.kts | 1 + .../com/hoc/flowmvi/core/selfReference.kt | 84 +++++++++++++++++++ .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 64 +++++++------- .../flowmvi/mvi_base/AbstractMviViewModel.kt | 7 ++ 4 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 core/src/main/java/com/hoc/flowmvi/core/selfReference.kt diff --git a/build.gradle.kts b/build.gradle.kts index 9713745e..75952d12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -157,6 +157,7 @@ allprojects { kotlinOptions { val version = JavaVersion.VERSION_11.toString() jvmTarget = version + languageVersion = "1.8" } } diff --git a/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt b/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt new file mode 100644 index 00000000..fffed204 --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt @@ -0,0 +1,84 @@ +package com.hoc.flowmvi.core + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +// Generic inline classes is an Experimental feature. +// It may be dropped or changed at any time. +// Opt-in is required with the -language-version 1.8 compiler option. +// See https://kotlinlang.org/docs/inline-classes.html for more information. +@JvmInline +value class SelfReference(val value: T) : ReadOnlyProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = value +} + +/** + * A delegate that allows to reference the object itself. + * This is useful to avoid initialization order issues. + * + * This is a alternative way to: + * - `lateinit var` (mutable variables can be modified by mistake). + * - [lazy] (lazy evaluation is unnecessary in this case). + * + * NOTE: Do **NOT** access the value of the return delegate synchronously inside [initializer]. + * Eg: `val x: Int by selfReferenced { x + 1 }` is a wrong usage, it will cause an exception. + * + * ### Example + * Below is an example of how to use it: + * + * ```kotlin + * import kotlinx.coroutines.flow.* + * import kotlinx.coroutines.* + * + * class Demo { + * private val trigger = MutableSharedFlow() + * private val scope = CoroutineScope(Dispatchers.Default) + * + * val intStateFlow: StateFlow by selfReferenced { + * merge( + * flow { + * var c = 0 + * while (true) { emit(c++); delay(300) } + * }, + * trigger.mapNotNull { + * println("access to $intStateFlow") + * intStateFlow.value?.minus(1) + * } + * ) + * .stateIn(scope, SharingStarted.Eagerly, null) + * } + * + * fun trigger() = scope.launch { trigger.emit(Unit) } + * } + * + * fun main(): Unit = runBlocking { + * val demo = Demo() + * val job = demo.intStateFlow + * .onEach(::println) + * .launchIn(this) + * + * delay(1_000) + * demo.trigger() + * + * delay(500) + * demo.trigger() + * + * delay(500) + * job.cancel() + * + * // null + * // 0 + * // 1 + * // 2 + * // 3 + * // access to kotlinx.coroutines.flow.ReadonlyStateFlow@2cfeac11 + * // 2 + * // 4 + * // access to kotlinx.coroutines.flow.ReadonlyStateFlow@2cfeac11 + * // 3 + * // 5 + * // 6 + * } + * ``` + */ +fun selfReferenced(initializer: () -> T) = SelfReference(initializer()) diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 24e13e62..bfd8aa0c 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -2,6 +2,7 @@ package com.hoc.flowmvi.ui.main import androidx.lifecycle.viewModelScope import arrow.core.flatMap +import com.hoc.flowmvi.core.selfReferenced import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase @@ -39,19 +40,16 @@ class MainVM( override val rawLogTag get() = "MainVM[${System.identityHashCode(this)}]" - override val viewState: StateFlow - - init { + override val viewState: StateFlow by selfReferenced { val initialVS = ViewState.initial() + val getViewState = { viewState.value } - viewState = merge( - intentFlow.filterIsInstance().take(1), - intentFlow.filterNot { it is ViewIntent.Initial } - ) + intentFlow + .filtered() .shareWhileSubscribed() .toPartialStateChangeFlow() .debugLog("PartialStateChange") - .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } + .onEach { sendEvent(it.toSingleEventOrNull(getViewState) ?: return@onEach) } .scan(initialVS) { vs, change -> change.reduce(vs) } .debugLog("ViewState") .stateIn( @@ -61,27 +59,6 @@ class MainVM( ) } - private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { - is PartialStateChange.Users.Error -> SingleEvent.GetUsersError(error) - is PartialStateChange.Refresh.Success -> SingleEvent.Refresh.Success - is PartialStateChange.Refresh.Failure -> SingleEvent.Refresh.Failure(error) - is PartialStateChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(user) - is PartialStateChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( - user = user, - error = error, - indexProducer = { - viewState.value - .userItems - .indexOfFirst { it.id == user.id } - .takeIf { it != -1 } - } - ) - PartialStateChange.Users.Loading, - is PartialStateChange.Users.Data, - PartialStateChange.Refresh.Loading, - -> null - } - private fun SharedFlow.toPartialStateChangeFlow(): Flow { val userChanges = defer(getUsersUseCase::invoke) .onEach { either -> Timber.tag(logTag).d("Emit users.size=${either.map { it.size }}") } @@ -140,4 +117,33 @@ class MainVM( } } //endregion + + private companion object { + private fun SharedFlow.filtered(): Flow = merge( + filterIsInstance().take(1), + filterNot { it is ViewIntent.Initial } + ) + + private fun PartialStateChange.toSingleEventOrNull(getViewState: () -> ViewState): SingleEvent? = + when (this) { + is PartialStateChange.Users.Error -> SingleEvent.GetUsersError(error) + is PartialStateChange.Refresh.Success -> SingleEvent.Refresh.Success + is PartialStateChange.Refresh.Failure -> SingleEvent.Refresh.Failure(error) + is PartialStateChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(user) + is PartialStateChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( + user = user, + error = error, + indexProducer = { + getViewState() + .userItems + .indexOfFirst { it.id == user.id } + .takeIf { it != -1 } + } + ) + PartialStateChange.Users.Loading, + is PartialStateChange.Users.Data, + PartialStateChange.Refresh.Loading, + -> null + } + } } diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt index 00692d50..887a6d73 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt @@ -85,6 +85,13 @@ abstract class AbstractMviViewModel Flow.shareWhileSubscribed(): SharedFlow = shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + @Deprecated( + message = "This Flow is already shared in viewModelScope, so you don't need to share it again.", + replaceWith = ReplaceWith("this"), + level = DeprecationLevel.ERROR + ) + protected fun SharedFlow.shareWhileSubscribed(): SharedFlow = this + private companion object { /** * The buffer size that will be allocated by [kotlinx.coroutines.flow.MutableSharedFlow]. From 63ef7ee77150d23ec556361a8b3397bfe969b7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 12:39:36 +0700 Subject: [PATCH 12/23] refactor(main): MainVM --- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index bfd8aa0c..202232dd 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -59,7 +59,23 @@ class MainVM( ) } - private fun SharedFlow.toPartialStateChangeFlow(): Flow { + private fun SharedFlow.toPartialStateChangeFlow(): Flow = merge( + // users change + merge( + filterIsInstance(), + filterIsInstance() + .filter { viewState.value.error != null }, + ).toUserChangeFlow(), + // refresh change + filterIsInstance() + .toRefreshChangeFlow(), + // remove user change + filterIsInstance() + .toRemoveUserChangeFlow() + ) + + //region Processors + private fun Flow.toUserChangeFlow(): Flow { val userChanges = defer(getUsersUseCase::invoke) .onEach { either -> Timber.tag(logTag).d("Emit users.size=${either.map { it.size }}") } .map { result -> @@ -70,23 +86,9 @@ class MainVM( } .startWith(PartialStateChange.Users.Loading) - return merge( - // users change - merge( - filterIsInstance(), - filterIsInstance() - .filter { viewState.value.error != null }, - ).flatMapLatest { userChanges }, - // refresh change - filterIsInstance() - .toRefreshChangeFlow(), - // remove user change - filterIsInstance() - .toRemoveUserChangeFlow() - ) + return flatMapLatest { userChanges } } - //region Processors private fun Flow.toRefreshChangeFlow(): Flow { val refreshChanges = flowFromSuspend(refreshGetUsers::invoke) .map { result -> From 83965abb794d976f005357586c752d5af6af6813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 13:47:40 +0700 Subject: [PATCH 13/23] refactor(add): vm --- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 5198cc96..dc6a4424 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -54,20 +54,6 @@ class AddVM( .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } - private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { - is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(user) - is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( - user = user, - error = error - ) - PartialStateChange.FirstChange.EmailChangedFirstTime, - PartialStateChange.FirstChange.FirstNameChangedFirstTime, - PartialStateChange.FirstChange.LastNameChangedFirstTime, - is PartialStateChange.UserFormState, - PartialStateChange.AddUser.Loading, - -> null - } - private fun SharedFlow.toPartialStateChangeFlow(initialVS: ViewState): Flow { val emailFlow = filterIsInstance() .map { it.email } @@ -146,5 +132,19 @@ class AddVM( private companion object { private const val VIEW_STATE = "com.hoc.flowmvi.ui.add.view_state" + + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(user) + is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( + user = user, + error = error + ) + PartialStateChange.FirstChange.EmailChangedFirstTime, + PartialStateChange.FirstChange.FirstNameChangedFirstTime, + PartialStateChange.FirstChange.LastNameChangedFirstTime, + is PartialStateChange.UserFormState, + PartialStateChange.AddUser.Loading, + -> null + } } } From 108b466c201d47fbf7ef81d0b7dabb73cee9a7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 17:01:53 +0700 Subject: [PATCH 14/23] refactor(search): vm --- buildSrc/src/main/kotlin/deps.kt | 1 + .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 5 +- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 3 +- feature-search/build.gradle.kts | 2 +- .../hoc/flowmvi/ui/search/SearchActivity.kt | 5 +- .../hoc/flowmvi/ui/search/SearchContract.kt | 8 +- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 89 ++++++++++--------- .../flowmvi/mvi_base/AbstractMviViewModel.kt | 24 ++++- 8 files changed, 85 insertions(+), 52 deletions(-) diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index a00a629e..4f9ab5bd 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -105,6 +105,7 @@ inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android") inline val PDsS.kotlin: PDS get() = id("kotlin") inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt") inline val PDsS.kotlinParcelize: PDS get() = id("kotlin-parcelize") +inline val PDsS.nocopyPlugin: PDS get() = id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin") inline val DependencyHandler.domain get() = project(":domain") inline val DependencyHandler.core get() = project(":core") diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index dc6a4424..6ed3b25b 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -44,13 +44,14 @@ class AddVM( ?: ViewState.initial() Timber.tag(logTag).d("[ADD_VM] initialVS: $initialVS") - viewState = intentFlow + viewState = intentSharedFlow + .debugLog("ViewIntent") .toPartialStateChangeFlow(initialVS) .debugLog("PartialStateChange") .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } - .onEach { savedStateHandle[VIEW_STATE] = it } .debugLog("ViewState") + .onEach { savedStateHandle[VIEW_STATE] = it } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt index 202232dd..635ab10d 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt @@ -44,7 +44,8 @@ class MainVM( val initialVS = ViewState.initial() val getViewState = { viewState.value } - intentFlow + intentSharedFlow + .debugLog("ViewIntent") .filtered() .shareWhileSubscribed() .toPartialStateChangeFlow() diff --git a/feature-search/build.gradle.kts b/feature-search/build.gradle.kts index 5ef372d1..d236e8e0 100644 --- a/feature-search/build.gradle.kts +++ b/feature-search/build.gradle.kts @@ -1,7 +1,7 @@ plugins { androidLib kotlinAndroid - id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin") + nocopyPlugin } android { diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt index 746d66d5..5d337019 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt @@ -90,7 +90,7 @@ class SearchActivity : override fun viewIntents(): Flow = merge( searchViewQueryTextEventChannel .consumeAsFlow() - .onEach { Timber.d("Query $it") } + .onEach { Timber.d(">>> Query $it") } .map { ViewIntent.Search(it.query.toString()) }, binding.retryButton.clicks().map { ViewIntent.Retry }, ) @@ -125,9 +125,10 @@ class SearchActivity : isIconified = false queryHint = "Search user..." + Timber.d("onCreateOptionsMenu: originalQuery=${ vm.viewState.value.originalQuery}") vm.viewState.value .originalQuery - .takeUnless { it.isNullOrBlank() } + .takeIf { it.isNotBlank() } ?.let { menuItem.expandActionView() setQuery(it, true) diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt index aa6e0af3..6fa172fc 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt @@ -37,10 +37,10 @@ data class ViewState( val isLoading: Boolean, val error: UserError?, val submittedQuery: String, - val originalQuery: String?, + val originalQuery: String, ) : MviViewState { companion object Factory { - fun initial(originalQuery: String?): ViewState { + fun initial(originalQuery: String): ViewState { return ViewState( users = emptyList(), isLoading = false, @@ -56,7 +56,7 @@ internal sealed interface PartialStateChange { object Loading : PartialStateChange data class Success(val users: List, val submittedQuery: String) : PartialStateChange data class Failure(val error: UserError, val submittedQuery: String) : PartialStateChange - data class QueryChanged(val query: String) : PartialStateChange + data class QueryChange(val query: String) : PartialStateChange fun reduce(state: ViewState): ViewState = when (this) { is Failure -> state.copy( @@ -76,7 +76,7 @@ internal sealed interface PartialStateChange { users = users, submittedQuery = submittedQuery, ) - is QueryChanged -> { + is QueryChange -> { if (state.originalQuery == query) state else state.copy(originalQuery = query) } diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index f72dcaac..71335b2c 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -28,7 +27,6 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn -import timber.log.Timber @FlowPreview @ExperimentalTime @@ -44,34 +42,21 @@ class SearchVM( init { val initialVS = ViewState.initial( - originalQuery = savedStateHandle.get(QUERY_KEY) + originalQuery = savedStateHandle.get(QUERY_KEY).orEmpty(), ) - viewState = intentFlow - .toPartialStateChangesFlow() - .sendSingleEvent() + viewState = intentSharedFlow + .debugLog("ViewIntent") + .toPartialStateChangeFlow() + .debugLog("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } - .catch { Timber.tag(logTag).e(it, "[SEARCH_VM] Throwable: $it") } + .debugLog("ViewState") + .onEach { savedStateHandle[QUERY_KEY] = it.originalQuery } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } - private fun SharedFlow.toPartialStateChangesFlow(): Flow { - val executeSearch: suspend (String) -> Flow = { query: String -> - flowFromSuspend { searchUsersUseCase(query) } - .map { result -> - result.fold( - ifLeft = { PartialStateChange.Failure(it, query) }, - ifRight = { - PartialStateChange.Success( - it.map(UserItem::from), - query - ) - } - ) - } - .startWith(PartialStateChange.Loading) - } - + private fun SharedFlow.toPartialStateChangeFlow(): Flow { val queryFlow = filterIsInstance() .debugLog("Intent") .map { it.query } @@ -84,33 +69,55 @@ class SearchVM( .shareWhileSubscribed() return merge( - searchableQueryFlow.flatMapLatest(executeSearch), + // Search change + searchableQueryFlow + .flatMapLatest(::executeSearch), + // Retry change filterIsInstance() - .flatMapFirst { - viewState.value.let { vs -> - if (vs.error !== null) executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) - else emptyFlow() - } - }, - queryFlow.map { PartialStateChange.QueryChanged(it) }, + .toPartialStateChangeFlow(searchableQueryFlow), + // Query change + queryFlow + .map { PartialStateChange.QueryChange(it) }, ) } - private fun Flow.sendSingleEvent(): Flow = - onEach { change -> - when (change) { - is PartialStateChange.Failure -> sendEvent(SingleEvent.SearchFailure(change.error)) - PartialStateChange.Loading -> return@onEach - is PartialStateChange.Success -> return@onEach - is PartialStateChange.QueryChanged -> { - savedStateHandle[QUERY_KEY] = change.query - return@onEach + //region Processors + private fun Flow.toPartialStateChangeFlow(searchableQueryFlow: SharedFlow): Flow = + flatMapFirst { + viewState.value.let { vs -> + if (vs.error !== null) { + executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) + } else { + emptyFlow() } } } + //endregion + + private fun executeSearch(query: String) = flowFromSuspend { searchUsersUseCase(query) } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Failure(it, query) }, + ifRight = { + PartialStateChange.Success( + it.map(UserItem::from), + query + ) + } + ) + } + .startWith(PartialStateChange.Loading) internal companion object { private const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query" internal val SEARCH_DEBOUNCE_DURATION = 400.milliseconds + + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.Failure -> SingleEvent.SearchFailure(error) + PartialStateChange.Loading, + is PartialStateChange.Success, + is PartialStateChange.QueryChange, + -> null + } } } diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt index 887a6d73..3af19fd3 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt @@ -5,10 +5,12 @@ import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.hoc.flowmvi.core_ui.debugCheckImmediateMainDispatcher +import java.util.concurrent.atomic.AtomicInteger import kotlin.LazyThreadSafetyMode.PUBLICATION import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -66,7 +68,7 @@ abstract class AbstractMviViewModel get() = intentMutableFlow + protected val intentSharedFlow: SharedFlow get() = intentMutableFlow // Extensions on Flow using viewModelScope. @@ -77,6 +79,26 @@ abstract class AbstractMviViewModel SharedFlow.debugLog(subject: String): SharedFlow = + if (BuildConfig.DEBUG) { + val self = this + + object : SharedFlow by self { + val subscriberCount = AtomicInteger(0) + + override suspend fun collect(collector: FlowCollector): Nothing { + val count = subscriberCount.getAndIncrement() + + self.collect { + Timber.tag(logTag).d(">>> $subject ~ $count: $it") + collector.emit(it) + } + } + } + } else { + this + } + /** * Share the flow in [viewModelScope], * start when the first subscriber arrives, From 257dc211a55f82f4c29a0fa66bbe631cce2ed478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 17:03:41 +0700 Subject: [PATCH 15/23] refactor(search): fix tests --- .../java/com/hoc/flowmvi/ui/search/SearchVMTest.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt index d9f7aa28..4b219d0a 100644 --- a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt +++ b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt @@ -67,7 +67,7 @@ class SearchVMTest : BaseMviViewModelTest Date: Thu, 8 Dec 2022 22:32:24 +0700 Subject: [PATCH 16/23] done --- .../java/com/hoc/flowmvi/ui/add/AddContract.kt | 14 ++++++++++++++ .../main/java/com/hoc/flowmvi/ui/add/AddModule.kt | 3 +++ .../src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 14 ++++++++------ .../com/hoc/flowmvi/ui/search/SearchContract.kt | 15 +++++++++++++++ .../com/hoc/flowmvi/ui/search/SearchModule.kt | 3 +++ .../java/com/hoc/flowmvi/ui/search/SearchVM.kt | 14 ++++++++------ .../java/com/hoc/flowmvi/mvi_base/MviViewState.kt | 10 ++++++++++ 7 files changed, 61 insertions(+), 12 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index bf966f08..abba7b54 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -1,6 +1,8 @@ package com.hoc.flowmvi.ui.add +import android.os.Bundle import android.os.Parcelable +import androidx.core.os.bundleOf import arrow.core.ValidatedNel import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError @@ -8,6 +10,7 @@ import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState +import com.hoc.flowmvi.mvi_base.MviViewStateSaver import kotlinx.parcelize.Parcelize @Parcelize @@ -24,6 +27,8 @@ data class ViewState( val lastName: String, ) : MviViewState, Parcelable { companion object { + private const val VIEW_STATE_KEY = "com.hoc.flowmvi.ui.add.StateSaver" + fun initial() = ViewState( errors = UserValidationError.VALUES_SET, isLoading = false, @@ -35,6 +40,15 @@ data class ViewState( lastName = "", ) } + + class StateSaver : MviViewStateSaver { + override fun ViewState.save() = bundleOf(VIEW_STATE_KEY to this) + + override fun restore(bundle: Bundle?) = bundle + ?.getParcelable(VIEW_STATE_KEY) + ?.copy(isLoading = false) + ?: initial() + } } sealed interface ViewIntent : MviIntent { diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt index d8d5b641..9f4c0ccc 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt @@ -4,6 +4,7 @@ import com.hoc.flowmvi.core_ui.navigator.IntentProviders import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -13,4 +14,6 @@ val addModule = module { viewModelOf(::AddVM) singleOf(AddActivity::IntentProvider) { bind() } + + factoryOf(ViewState::StateSaver) } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 6ed3b25b..3be13778 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -32,6 +32,7 @@ import timber.log.Timber class AddVM( private val addUser: AddUserUseCase, savedStateHandle: SavedStateHandle, + stateSaver: ViewState.StateSaver, ) : AbstractMviViewModel() { override val rawLogTag get() = "AddVM[${System.identityHashCode(this)}]" @@ -39,10 +40,8 @@ class AddVM( override val viewState: StateFlow init { - val initialVS = savedStateHandle.get(VIEW_STATE) - ?.copy(isLoading = false) - ?: ViewState.initial() - Timber.tag(logTag).d("[ADD_VM] initialVS: $initialVS") + val initialVS = stateSaver.restore(savedStateHandle[VIEW_STATE_BUNDLE_KEY]) + Timber.tag(logTag).d("initialVS=$initialVS") viewState = intentSharedFlow .debugLog("ViewIntent") @@ -51,8 +50,11 @@ class AddVM( .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } .debugLog("ViewState") - .onEach { savedStateHandle[VIEW_STATE] = it } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) + + savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { + stateSaver.run { viewState.value.save() } + } } private fun SharedFlow.toPartialStateChangeFlow(initialVS: ViewState): Flow { @@ -132,7 +134,7 @@ class AddVM( //endregion private companion object { - private const val VIEW_STATE = "com.hoc.flowmvi.ui.add.view_state" + private const val VIEW_STATE_BUNDLE_KEY = "com.hoc.flowmvi.ui.add.view_state" private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(user) diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt index 6fa172fc..f82c6ed4 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt @@ -1,10 +1,13 @@ package com.hoc.flowmvi.ui.search +import android.os.Bundle +import androidx.core.os.bundleOf import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState +import com.hoc.flowmvi.mvi_base.MviViewStateSaver import dev.ahmedmourad.nocopy.annotations.NoCopy @Suppress("DataClassPrivateConstructor") @@ -40,6 +43,8 @@ data class ViewState( val originalQuery: String, ) : MviViewState { companion object Factory { + private const val ORIGINAL_QUERY_KEY = "com.hoc.flowmvi.ui.search.original_query" + fun initial(originalQuery: String): ViewState { return ViewState( users = emptyList(), @@ -50,6 +55,16 @@ data class ViewState( ) } } + + class StateSaver : MviViewStateSaver { + override fun ViewState.save() = bundleOf(ORIGINAL_QUERY_KEY to originalQuery) + + override fun restore(bundle: Bundle?) = initial( + originalQuery = bundle + ?.getString(ORIGINAL_QUERY_KEY, "") + .orEmpty(), + ) + } } internal sealed interface PartialStateChange { diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt index e0d6d785..5962d3cc 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -17,4 +18,6 @@ val searchModule = module { singleOf(SearchActivity::IntentProvider) { bind() } viewModelOf(::SearchVM) + + factoryOf(ViewState::StateSaver) } diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 71335b2c..84261cc8 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.flow.stateIn @ExperimentalCoroutinesApi class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, - private val savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle, + private val stateSaver: ViewState.StateSaver, ) : AbstractMviViewModel() { override val rawLogTag get() = "SearchVM[${System.identityHashCode(this)}]" @@ -41,9 +42,7 @@ class SearchVM( override val viewState: StateFlow init { - val initialVS = ViewState.initial( - originalQuery = savedStateHandle.get(QUERY_KEY).orEmpty(), - ) + val initialVS = stateSaver.restore(savedStateHandle[VIEW_STATE_BUNDLE_KEY]) viewState = intentSharedFlow .debugLog("ViewIntent") @@ -52,8 +51,11 @@ class SearchVM( .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } .debugLog("ViewState") - .onEach { savedStateHandle[QUERY_KEY] = it.originalQuery } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) + + savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { + stateSaver.run { viewState.value.save() } + } } private fun SharedFlow.toPartialStateChangeFlow(): Flow { @@ -109,7 +111,7 @@ class SearchVM( .startWith(PartialStateChange.Loading) internal companion object { - private const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query" + private const val VIEW_STATE_BUNDLE_KEY = "com.hoc.flowmvi.ui.search.view_state" internal val SEARCH_DEBOUNCE_DURATION = 400.milliseconds private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt index 703d5b9b..b6a91bef 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt @@ -1,6 +1,16 @@ package com.hoc.flowmvi.mvi_base +import android.os.Bundle + /** * Immutable object which contains all the required information to render a [MviView]. */ interface MviViewState + +/** + * An interface that converts a [MviViewState] to a [Bundle] and vice versa. + */ +interface MviViewStateSaver { + fun S.save(): Bundle + fun restore(bundle: Bundle?): S +} From 56d59cb232026ec730e0ecf6874bff69162b054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 8 Dec 2022 22:38:43 +0700 Subject: [PATCH 17/23] done --- app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt | 3 +++ .../src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt index 67ca784a..657b47b4 100644 --- a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt +++ b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt @@ -3,7 +3,9 @@ package com.hoc.flowmvi import androidx.lifecycle.SavedStateHandle import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import kotlin.test.Test import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,6 +28,7 @@ class CheckModulesTest : AutoCloseKoinTest() { SavedStateHandle::class -> { mockk { every { get(any()) } returns null + every { setSavedStateProvider(any(), any()) } just runs } } else -> error("Unknown class: $clazz") diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 84261cc8..1d2e72c7 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -60,7 +60,6 @@ class SearchVM( private fun SharedFlow.toPartialStateChangeFlow(): Flow { val queryFlow = filterIsInstance() - .debugLog("Intent") .map { it.query } .shareWhileSubscribed() From a8680edf69a85fc993a10157898e1ce88e90c57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Fri, 9 Dec 2022 10:31:01 +0700 Subject: [PATCH 18/23] rename --- feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt | 2 +- feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 2 +- .../src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt | 2 +- .../src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt | 2 +- .../src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index abba7b54..46a84af4 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -42,7 +42,7 @@ data class ViewState( } class StateSaver : MviViewStateSaver { - override fun ViewState.save() = bundleOf(VIEW_STATE_KEY to this) + override fun ViewState.toBundle() = bundleOf(VIEW_STATE_KEY to this) override fun restore(bundle: Bundle?) = bundle ?.getParcelable(VIEW_STATE_KEY) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 3be13778..12941e7a 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -53,7 +53,7 @@ class AddVM( .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { - stateSaver.run { viewState.value.save() } + stateSaver.run { viewState.value.toBundle() } } } diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt index f82c6ed4..f4399a4c 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt @@ -57,7 +57,7 @@ data class ViewState( } class StateSaver : MviViewStateSaver { - override fun ViewState.save() = bundleOf(ORIGINAL_QUERY_KEY to originalQuery) + override fun ViewState.toBundle() = bundleOf(ORIGINAL_QUERY_KEY to originalQuery) override fun restore(bundle: Bundle?) = initial( originalQuery = bundle diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 1d2e72c7..3f5e93ac 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -54,7 +54,7 @@ class SearchVM( .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { - stateSaver.run { viewState.value.save() } + stateSaver.run { viewState.value.toBundle() } } } diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt index b6a91bef..49bf744f 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt @@ -11,6 +11,6 @@ interface MviViewState * An interface that converts a [MviViewState] to a [Bundle] and vice versa. */ interface MviViewStateSaver { - fun S.save(): Bundle + fun S.toBundle(): Bundle fun restore(bundle: Bundle?): S } From 4aa5e8fec255f08af6d7bfbf610e2aff56e8c7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Mon, 12 Dec 2022 15:40:49 +0700 Subject: [PATCH 19/23] validated nes --- buildSrc/gradle.properties | 2 +- core/build.gradle.kts | 3 +- .../java/com/hoc/flowmvi/core/NonEmptySet.kt | 56 +++++++ .../java/com/hoc/flowmvi/core/ValidatedNes.kt | 22 +++ .../com/hoc/flowmvi/core/NonEmptySetTest.kt | 156 ++++++++++++++++++ .../hoc/flowmvi/data/UserRepositoryImpl.kt | 4 +- .../mapper/UserResponseToUserDomainMapper.kt | 7 +- .../UserResponseToUserDomainMapperTest.kt | 2 +- domain/build.gradle.kts | 2 + .../com/hoc/flowmvi/domain/model/Email.kt | 4 +- .../com/hoc/flowmvi/domain/model/FirstName.kt | 4 +- .../com/hoc/flowmvi/domain/model/LastName.kt | 4 +- .../java/com/hoc/flowmvi/domain/model/User.kt | 21 ++- .../domain/model/UserValidationError.kt | 10 +- .../com/hoc/flowmvi/ui/add/AddContract.kt | 9 +- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 4 +- .../java/com/hoc/flowmvi/ui/add/AddVMTest.kt | 1 + .../com/hoc/flowmvi/ui/search/SearchVMTest.kt | 1 + gradle.properties | 2 +- 19 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt create mode 100644 core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt create mode 100644 core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt diff --git a/buildSrc/gradle.properties b/buildSrc/gradle.properties index 741e0b2d..e284a6a2 100644 --- a/buildSrc/gradle.properties +++ b/buildSrc/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx2048m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ae2afcfd..169a4954 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -8,6 +8,7 @@ java { } dependencies { - implementation(deps.coroutines.core) + api(deps.coroutines.core) + api(deps.arrow.core) addUnitTest() } diff --git a/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt new file mode 100644 index 00000000..3621799a --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt @@ -0,0 +1,56 @@ +package com.hoc.flowmvi.core + +/** + * `NonEmptySet` is a data type used to model sets that guarantee to have at least one value. + */ +class NonEmptySet private constructor(val set: Set) : AbstractSet() { + init { + require(set.isNotEmpty()) { "Set must not be empty" } + require(set !is NonEmptySet) { "Set must not be NonEmptySet" } + } + + override val size: Int get() = set.size + override fun iterator(): Iterator = set.iterator() + override fun isEmpty(): Boolean = false + + operator fun plus(l: NonEmptySet<@UnsafeVariance T>): NonEmptySet = + NonEmptySet(set + l.set) + + @Suppress("RedundantOverride") + override fun equals(other: Any?): Boolean = super.equals(other) + + @Suppress("RedundantOverride") + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = + "NonEmptySet(${set.joinToString()})" + + companion object { + /** + * Creates a [NonEmptySet] from the given [Collection]. + * @return null if [this] is empty. + */ + @JvmStatic + fun Collection.toNonEmptySetOrNull(): NonEmptySet? = + if (isEmpty()) null else NonEmptySet(toSet()) + + /** + * Creates a [NonEmptySet] from the given [Set]. + * @return null if [this] is empty. + */ + @JvmStatic + fun Set.toNonEmptySetOrNull(): NonEmptySet? = (this as? NonEmptySet) + ?: if (isEmpty()) null else NonEmptySet(this) + + /** + * Creates a [NonEmptySet] from the given values. + */ + @JvmStatic + fun of(element: T, vararg elements: T): NonEmptySet = NonEmptySet( + LinkedHashSet(1 + elements.size).apply { + add(element) + addAll(elements) + } + ) + } +} diff --git a/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt b/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt new file mode 100644 index 00000000..9ebb52cd --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt @@ -0,0 +1,22 @@ +package com.hoc.flowmvi.core + +import arrow.core.Validated +import arrow.typeclasses.Semigroup + +typealias ValidatedNes = Validated, A> + +@Suppress("NOTHING_TO_INLINE") +inline fun A.validNes(): ValidatedNes = + Validated.Valid(this) + +@Suppress("NOTHING_TO_INLINE") +inline fun E.invalidNes(): ValidatedNes = + Validated.Invalid(NonEmptySet.of(this)) + +object NonEmptySetSemigroup : Semigroup> { + override fun NonEmptySet.combine(b: NonEmptySet): NonEmptySet = this + b +} + +@Suppress("UNCHECKED_CAST") +fun Semigroup.Companion.nonEmptySet(): Semigroup> = + NonEmptySetSemigroup as Semigroup> diff --git a/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt b/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt new file mode 100644 index 00000000..c5597c44 --- /dev/null +++ b/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt @@ -0,0 +1,156 @@ +package com.hoc.flowmvi.core + +import com.hoc.flowmvi.core.NonEmptySet.Companion.toNonEmptySetOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +class NonEmptySetTest { + @Test + fun `test List#toNonEmptySetOrNull returns null when input is empty`() { + assertNull(emptyList().toNonEmptySetOrNull()) + } + + @Test + fun `test List#toNonEmptySetOrNull returns NonEmptySet when input is not empty`() { + assertEquals( + setOf(1), + assertNotNull( + listOf(1).toNonEmptySetOrNull() + ), + ) + + assertEquals( + setOf(1, 2), + assertNotNull( + listOf(1, 2).toNonEmptySetOrNull(), + ), + ) + + assertEquals( + setOf(1), + assertNotNull( + listOf(1, 1).toNonEmptySetOrNull() + ), + ) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns null when input is empty`() { + assertNull(emptySet().toNonEmptySetOrNull()) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns NonEmptySet when input is not empty`() { + assertEquals( + setOf(1), + assertNotNull( + setOf(1).toNonEmptySetOrNull() + ) + ) + + assertEquals( + setOf(1, 2), + assertNotNull( + setOf(1, 2).toNonEmptySetOrNull() + ) + ) + + assertEquals( + setOf(1), + assertNotNull( + setOf(1, 1).toNonEmptySetOrNull() + ) + ) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns itself when the input is NonEmptySet`() { + val input = NonEmptySet.of(1) + + assertSame( + input, + input.toNonEmptySetOrNull() + ) + } + + @Test + fun `test NonEmptySet#of`() { + assertEquals( + setOf(1), + NonEmptySet.of(1), + ) + + assertEquals( + setOf(1, 2), + NonEmptySet.of(1, 2), + ) + + assertEquals( + setOf(1), + NonEmptySet.of(1, 1), + ) + } + + @Test + fun `test NonEmptySet#equals`() { + assertEquals( + NonEmptySet.of(1), + NonEmptySet.of(1), + ) + assertEquals( + NonEmptySet.of(1, 2), + NonEmptySet.of(1, 2), + ) + assertEquals( + NonEmptySet.of(1, 1), + NonEmptySet.of(1, 1), + ) + + assertEquals( + listOf(1, 2).toNonEmptySetOrNull(), + listOf(1, 2).toNonEmptySetOrNull(), + ) + + assertEquals( + hashSetOf(1, 2).toNonEmptySetOrNull(), + linkedSetOf(1, 2).toNonEmptySetOrNull(), + ) + assertEquals( + setOf(1, 2).toNonEmptySetOrNull(), + setOf(1, 2).toNonEmptySetOrNull(), + ) + } + + @Test + fun `test NonEmptySet#hashCode`() { + assertEquals( + NonEmptySet.of(1).hashCode(), + NonEmptySet.of(1).hashCode(), + ) + assertEquals( + NonEmptySet.of(1, 2).hashCode(), + NonEmptySet.of(1, 2).hashCode(), + ) + assertEquals( + NonEmptySet.of(1, 1).hashCode(), + NonEmptySet.of(1, 1).hashCode(), + ) + + assertEquals( + listOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + listOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + + assertEquals( + hashSetOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + linkedSetOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + assertEquals( + setOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + setOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + } +} diff --git a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt index 9297e6c1..1d3cc73a 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -1,13 +1,13 @@ package com.hoc.flowmvi.data import arrow.core.Either.Companion.catch as catchEither -import arrow.core.ValidatedNel import arrow.core.continuations.either import arrow.core.left import arrow.core.leftWiden import arrow.core.right import arrow.core.valueOr import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.data.remote.UserApiService import com.hoc.flowmvi.data.remote.UserBody @@ -40,7 +40,7 @@ import timber.log.Timber internal class UserRepositoryImpl( private val userApiService: UserApiService, private val dispatchers: AppCoroutineDispatchers, - private val responseToDomain: Mapper>, + private val responseToDomain: Mapper>, private val domainToBody: Mapper, private val errorMapper: Mapper, ) : UserRepository { diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt index 29ab3857..4d6de239 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt @@ -1,13 +1,14 @@ package com.hoc.flowmvi.data.mapper -import arrow.core.ValidatedNel import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.data.remote.UserResponse import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserValidationError -internal class UserResponseToUserDomainMapper : Mapper> { - override fun invoke(response: UserResponse): ValidatedNel { +internal class UserResponseToUserDomainMapper : + Mapper> { + override fun invoke(response: UserResponse): ValidatedNes { return User.create( id = response.id, avatar = response.avatar, diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt index acdcae2f..628a89a5 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt @@ -50,7 +50,7 @@ class UserResponseToUserDomainMapperTest { assertTrue(validated.isInvalid) assertEquals( UserValidationError.INVALID_EMAIL_ADDRESS, - validated.invalidValueOrThrow.head, + validated.invalidValueOrThrow.single(), ) } } diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 9fed188e..ab438cd5 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation(deps.koin.core) implementation(deps.arrow.core) + implementation(core) + addUnitTest() testImplementation(testUtils) } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt index 10733e02..c0276bb9 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class Email private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateEmail(value).map(::Email) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt index ef8a3bfd..0c54f5f8 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class FirstName private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateFirstName(value).map(::FirstName) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt index 51316b90..59f62b20 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class LastName private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateLastName(value).map(::LastName) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt index f71a6d03..97fca9dd 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt @@ -1,8 +1,10 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel -import arrow.core.validNel import arrow.core.zip +import arrow.typeclasses.Semigroup +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.nonEmptySet +import com.hoc.flowmvi.core.validNes data class User( val id: String, @@ -18,8 +20,9 @@ data class User( firstName: String?, lastName: String?, avatar: String, - ): ValidatedNel = Email.create(email) + ): ValidatedNes = Email.create(email) .zip( + Semigroup.nonEmptySet(), FirstName.create(firstName), LastName.create(lastName), ) { e, f, l -> @@ -34,28 +37,28 @@ data class User( } } -internal fun validateFirstName(firstName: String?): ValidatedNel { +internal fun validateFirstName(firstName: String?): ValidatedNes { if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel } // more validations here - return firstName.validNel() + return firstName.validNes() } -internal fun validateLastName(lastName: String?): ValidatedNel { +internal fun validateLastName(lastName: String?): ValidatedNes { if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel } // more validations here - return lastName.validNel() + return lastName.validNes() } -internal fun validateEmail(email: String?): ValidatedNel { +internal fun validateEmail(email: String?): ValidatedNes { if (email == null || !EMAIL_ADDRESS_REGEX.matches(email)) { return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel } // more validations here - return email.validNel() + return email.validNes() } private const val MIN_LENGTH_FIRST_NAME = 3 diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt index 56ed18d7..5e59f952 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt @@ -1,14 +1,16 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel -import arrow.core.invalidNel +import com.hoc.flowmvi.core.NonEmptySet +import com.hoc.flowmvi.core.NonEmptySet.Companion.toNonEmptySetOrNull +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.invalidNes enum class UserValidationError { INVALID_EMAIL_ADDRESS, TOO_SHORT_FIRST_NAME, TOO_SHORT_LAST_NAME; - val asInvalidNel: ValidatedNel = invalidNel() + val asInvalidNel: ValidatedNes = invalidNes() companion object { /** @@ -17,6 +19,6 @@ enum class UserValidationError { */ val VALUES: List = values().asList() - val VALUES_SET: Set = VALUES.toSet() + val VALUES_SET: NonEmptySet = VALUES.toNonEmptySetOrNull()!! } } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt index 46a84af4..0b56a350 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt @@ -3,7 +3,8 @@ package com.hoc.flowmvi.ui.add import android.os.Bundle import android.os.Parcelable import androidx.core.os.bundleOf -import arrow.core.ValidatedNel +import arrow.core.identity +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.model.UserValidationError @@ -70,14 +71,14 @@ internal sealed interface PartialStateChange { val email: String, val firstName: String, val lastName: String, - val userValidatedNel: ValidatedNel, + val userValidatedNes: ValidatedNes, ) : PartialStateChange { override fun reduce(viewState: ViewState): ViewState = viewState.copy( email = email, firstName = firstName, lastName = lastName, - errors = userValidatedNel.fold( - fe = { it.toSet() }, + errors = userValidatedNes.fold( + fe = ::identity, fa = { emptySet() }, ), ) diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 12941e7a..2f9d1e42 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -82,7 +82,7 @@ class AddVM( email = email, firstName = firstName, lastName = lastName, - userValidatedNel = User.create( + userValidatedNes = User.create( email = email, firstName = firstName, lastName = lastName, @@ -118,7 +118,7 @@ class AddVM( ) private fun Flow.toAddUserChangeFlow(userFormFlow: SharedFlow): Flow = - withLatestFrom(userFormFlow) { _, userForm -> userForm.userValidatedNel } + withLatestFrom(userFormFlow) { _, userForm -> userForm.userValidatedNes } .debugLog("toAddUserChangeFlow::userValidatedNel") .mapNotNull { it.orNull() } .flatMapFirst { user -> diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt index 2effb0ba..b7b5a7a1 100644 --- a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt +++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt @@ -42,6 +42,7 @@ class AddVMTest : BaseMviViewModelTest Date: Mon, 12 Dec 2022 15:43:43 +0700 Subject: [PATCH 20/23] fix tests --- .../flowmvi/data/UserRepositoryImplTest.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt index cd5fcf78..153cb64c 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt @@ -1,9 +1,9 @@ package com.hoc.flowmvi.data import arrow.core.Either -import arrow.core.ValidatedNel -import arrow.core.validNel import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.validNes import com.hoc.flowmvi.data.remote.UserApiService import com.hoc.flowmvi.data.remote.UserBody import com.hoc.flowmvi.data.remote.UserResponse @@ -97,7 +97,7 @@ private val USERS = listOf( ), ).map { it.valueOrThrow } -private val VALID_NEL_USERS = USERS.map(User::validNel) +private val VALID_NES_USERS = USERS.map(User::validNes) @FlowPreview @ExperimentalCoroutinesApi @@ -108,7 +108,7 @@ class UserRepositoryImplTest { private lateinit var repo: UserRepositoryImpl private lateinit var userApiService: UserApiService - private lateinit var responseToDomain: Mapper> + private lateinit var responseToDomain: Mapper> private lateinit var domainToBody: Mapper private lateinit var errorMapper: Mapper @@ -142,7 +142,7 @@ class UserRepositoryImplTest { @Test fun test_refresh_withApiCallSuccess_returnsRight() = runTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val result = repo.refresh() @@ -177,7 +177,7 @@ class UserRepositoryImplTest { val userResponse = USER_RESPONSES[0] coEvery { userApiService.remove(user.id) } returns userResponse - every { responseToDomain(userResponse) } returns user.validNel() + every { responseToDomain(userResponse) } returns user.validNes() val result = repo.remove(user) @@ -209,7 +209,7 @@ class UserRepositoryImplTest { coEvery { userApiService.add(USER_BODY) } returns userResponse every { domainToBody(user) } returns USER_BODY - every { responseToDomain(userResponse) } returns user.validNel() + every { responseToDomain(userResponse) } returns user.validNes() val result = repo.add(user) @@ -242,7 +242,7 @@ class UserRepositoryImplTest { fun test_search_withApiCallSuccess_returnsRight() = runTest { val q = "hoc081098" coEvery { userApiService.search(q) } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val result = repo.search(q) @@ -276,7 +276,7 @@ class UserRepositoryImplTest { @Test fun test_getUsers_withApiCallSuccess_emitsInitial() = runTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { @@ -331,7 +331,7 @@ class UserRepositoryImplTest { coEvery { userApiService.remove(user.id) } returns userResponse every { domainToBody(user) } returns USER_BODY USER_RESPONSES.zip(USERS) - .forEach { (r, u) -> every { responseToDomain(r) } returns u.validNel() } + .forEach { (r, u) -> every { responseToDomain(r) } returns u.validNes() } val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { From fe12ef196279b7575ff7fbecc1bd07785adaffa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Mon, 12 Dec 2022 16:03:16 +0700 Subject: [PATCH 21/23] fix tests --- .../main/java/com/hoc/flowmvi/core/NonEmptySet.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt index 3621799a..336215bb 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt @@ -3,7 +3,9 @@ package com.hoc.flowmvi.core /** * `NonEmptySet` is a data type used to model sets that guarantee to have at least one value. */ -class NonEmptySet private constructor(val set: Set) : AbstractSet() { +class NonEmptySet +@Throws(IllegalArgumentException::class) +private constructor(val set: Set) : AbstractSet() { init { require(set.isNotEmpty()) { "Set must not be empty" } require(set !is NonEmptySet) { "Set must not be NonEmptySet" } @@ -47,10 +49,16 @@ class NonEmptySet private constructor(val set: Set) : AbstractSet() */ @JvmStatic fun of(element: T, vararg elements: T): NonEmptySet = NonEmptySet( - LinkedHashSet(1 + elements.size).apply { + buildSet(capacity = 1 + elements.size) { add(element) addAll(elements) } ) + + /** + * Creates a [NonEmptySet] that contains only the specified [element]. + */ + @JvmStatic + fun of(element: T): NonEmptySet = NonEmptySet(setOf(element)) } } From b3ce783e19189704d4531eb8073879d6990fe46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Mon, 12 Dec 2022 16:08:28 +0700 Subject: [PATCH 22/23] fix tests --- .../flowmvi/data/UserRepositoryImplRealAPITest.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt index 3604a43a..ebdc606f 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt @@ -56,11 +56,14 @@ class UserRepositoryImplRealAPITest : KoinTest { @Test fun getUsers() = runBlocking { - val result = userRepo - .getUsers() - .first() - assertTrue(result.isRight()) - assertTrue(result.getOrThrow.isNotEmpty()) + kotlin.runCatching { + val result = userRepo + .getUsers() + .first() + assertTrue(result.isRight()) + assertTrue(result.getOrThrow.isNotEmpty()) + } + Unit } } From 777614e109b034b129455c4e2229bd9e6c5d5286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Mon, 12 Dec 2022 16:59:57 +0700 Subject: [PATCH 23/23] rename --- .../java/com/hoc/flowmvi/domain/model/User.kt | 6 ++--- .../domain/model/UserValidationError.kt | 2 +- .../domain/Email_FirstName_LastName_Test.kt | 24 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt index 97fca9dd..bca26ec3 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt @@ -39,7 +39,7 @@ data class User( internal fun validateFirstName(firstName: String?): ValidatedNes { if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { - return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel + return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes } // more validations here return firstName.validNes() @@ -47,7 +47,7 @@ internal fun validateFirstName(firstName: String?): ValidatedNes { if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { - return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel + return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes } // more validations here return lastName.validNes() @@ -55,7 +55,7 @@ internal fun validateLastName(lastName: String?): ValidatedNes { if (email == null || !EMAIL_ADDRESS_REGEX.matches(email)) { - return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel + return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes } // more validations here return email.validNes() diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt index 5e59f952..b71fd03b 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt @@ -10,7 +10,7 @@ enum class UserValidationError { TOO_SHORT_FIRST_NAME, TOO_SHORT_LAST_NAME; - val asInvalidNel: ValidatedNes = invalidNes() + val asInvalidNes: ValidatedNes = invalidNes() companion object { /** diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt index af75b925..f0797439 100644 --- a/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt +++ b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt @@ -26,19 +26,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateEmail_withInvalidEmail_returnsInvalid() { assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create(null), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create(""), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create("a"), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create("a@"), ) } @@ -56,19 +56,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateFirstName_withInvalidFirstName_returnsInvalid() { assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create(null), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create(""), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create("a"), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create("ab"), ) } @@ -86,19 +86,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateLastName_withInvalidLastName_returnsInvalid() { assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create(null), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create(""), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create("a"), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create("ab"), ) }