From 6f79b75f69b21aef17f5ed5cad5c846cd995ee0a Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 20:33:20 +0700 Subject: [PATCH 01/21] refactor --- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 29 +++++---------- .../com/hoc/flowmvi/mvi_base/MviViewModel.kt | 36 ++++++++++++++++++- 2 files changed, 44 insertions(+), 21 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 5018d79c..5c291d67 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 @@ -1,19 +1,15 @@ package com.hoc.flowmvi.ui.main import android.util.Log -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.hoc.flowmvi.core.unit import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase -import com.hoc.flowmvi.mvi_base.MviViewModel +import com.hoc.flowmvi.mvi_base.BaseMviViewModel import com.hoc081098.flowext.flatMapFirst import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow @@ -29,7 +25,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn @@ -41,20 +36,16 @@ class MainVM( private val getUsersUseCase: GetUsersUseCase, private val refreshGetUsers: RefreshGetUsersUseCase, private val removeUser: RemoveUserUseCase, -) : ViewModel(), MviViewModel { - private val _eventChannel = Channel(Channel.BUFFERED) - private val _intentFlow = MutableSharedFlow(extraBufferCapacity = 64) +) : BaseMviViewModel() { override val viewState: StateFlow - override val singleEvent: Flow get() = _eventChannel.receiveAsFlow() - override fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent).unit init { val initialVS = ViewState.initial() viewState = merge( - _intentFlow.filterIsInstance().take(1), - _intentFlow.filterNot { it is ViewIntent.Initial } + intentFlow.filterIsInstance().take(1), + intentFlow.filterNot { it is ViewIntent.Initial } ) .toPartialChangeFlow() .sendSingleEvent() @@ -82,7 +73,7 @@ class MainVM( is PartialChange.GetUser.Data -> return@onEach PartialChange.Refresh.Loading -> return@onEach } - _eventChannel.send(event) + sendEvent(event) } } @@ -110,18 +101,18 @@ class MainVM( return merge( filterIsInstance() - .logIntent() + .log("Intent") .flatMapConcat { getUserChanges }, filterIsInstance() .filter { viewState.value.let { !it.isLoading && it.error === null } } - .logIntent() + .log("Intent") .flatMapFirst { refreshChanges }, filterIsInstance() .filter { viewState.value.error != null } - .logIntent() + .log("Intent") .flatMapFirst { getUserChanges }, filterIsInstance() - .logIntent() + .log("Intent") .map { it.user } .flatMapMerge { userItem -> flow { @@ -139,8 +130,6 @@ class MainVM( } ) } - - private fun Flow.logIntent() = onEach { Log.d("MainVM", "## Intent: $it") } } private fun defer(flowFactory: () -> Flow): Flow = flow { emitAll(flowFactory()) } diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index cf046f03..38dcb5bf 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -1,7 +1,13 @@ package com.hoc.flowmvi.mvi_base +import android.util.Log +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow /** * Object that will subscribes to a MviView's [MviIntent]s, @@ -16,5 +22,33 @@ interface MviViewModel { val singleEvent: Flow - fun processIntent(intent: I) + suspend fun processIntent(intent: I) +} + +abstract class BaseMviViewModel : + MviViewModel, ViewModel() { + private val tag by lazy { this::class.java.simpleName.take(23) } + + private val eventChannel = Channel(Channel.UNLIMITED) + private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) + + override val singleEvent: Flow get() = eventChannel.receiveAsFlow() + override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) + + protected suspend fun sendEvent(event: E) = eventChannel.send(event) + protected val intentFlow: Flow get() = intentMutableFlow + + protected fun Flow.log(subject: String) = onEach { Log.d(tag, ">>> $subject: $it") } + + companion object { + /** + * The buffer size that will be allocated by [kotlinx.coroutines.flow.MutableSharedFlow]. + * If it falls behind by more than 64 state updates, it will start suspending. + * Slow consumers should consider using `stateFlow.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)`. + * + * The internally allocated buffer is replay + extraBufferCapacity but always allocates 2^n space. + * We use replay=0 so buffer = 64. + */ + const val SubscriberBufferSize = 64 + } } From b9485278762374aeebcc6494140d36621f1fd20d Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 20:35:16 +0700 Subject: [PATCH 02/21] refactor --- .../src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index 38dcb5bf..8c67d9b1 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow @@ -36,7 +37,7 @@ abstract class BaseMviViewModel get() = intentMutableFlow + protected val intentFlow: SharedFlow get() = intentMutableFlow protected fun Flow.log(subject: String) = onEach { Log.d(tag, ">>> $subject: $it") } From e62bc9c91d000558255d4257181889d762e0376e Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 20:35:43 +0700 Subject: [PATCH 03/21] refactor --- .../src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index 8c67d9b1..3e13b2eb 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -41,7 +41,7 @@ abstract class BaseMviViewModel Flow.log(subject: String) = onEach { Log.d(tag, ">>> $subject: $it") } - companion object { + private companion object { /** * The buffer size that will be allocated by [kotlinx.coroutines.flow.MutableSharedFlow]. * If it falls behind by more than 64 state updates, it will start suspending. @@ -50,6 +50,6 @@ abstract class BaseMviViewModel Date: Wed, 3 Nov 2021 20:47:35 +0700 Subject: [PATCH 04/21] lazy --- .../src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index 3e13b2eb..d5bcfc8b 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -28,7 +28,7 @@ interface MviViewModel { abstract class BaseMviViewModel : MviViewModel, ViewModel() { - private val tag by lazy { this::class.java.simpleName.take(23) } + private val tag by lazy(LazyThreadSafetyMode.PUBLICATION) { this::class.java.simpleName.take(23) } private val eventChannel = Channel(Channel.UNLIMITED) private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) From e9d1f4c1c0cd2b5fa99493bb302226cbbb39af75 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 21:31:31 +0700 Subject: [PATCH 05/21] refactor AddVM --- .../com/hoc/flowmvi/ui/add/AddActivity.kt | 14 +- .../com/hoc/flowmvi/ui/add/AddContract.kt | 23 ++- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 179 ++++++++---------- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 3 +- .../com/hoc/flowmvi/mvi_base/MviViewModel.kt | 19 +- 5 files changed, 117 insertions(+), 121 deletions(-) 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 b33fc592..a86bd3c8 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 @@ -16,6 +16,7 @@ import com.hoc.flowmvi.core.firstChange import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.textChanges import com.hoc.flowmvi.core.toast +import com.hoc.flowmvi.mvi_base.MviView import com.hoc.flowmvi.ui.add.databinding.ActivityAddBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -27,7 +28,9 @@ import kotlinx.coroutines.flow.onEach import org.koin.androidx.viewmodel.ext.android.viewModel @ExperimentalCoroutinesApi -class AddActivity : AppCompatActivity(R.layout.activity_add) { +class AddActivity : + AppCompatActivity(R.layout.activity_add), + MviView { private val addVM by viewModel() private val addBinding by viewBinding() @@ -56,12 +59,12 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) { .collectIn(this) { handleSingleEvent(it) } // pass view intent to view model - intents() + viewIntents() .onEach { addVM.processIntent(it) } .launchIn(lifecycleScope) } - private fun handleSingleEvent(event: SingleEvent) { + override fun handleSingleEvent(event: SingleEvent) { Log.d("###", "Event=$event") return when (event) { @@ -76,7 +79,7 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) { } } - private fun render(viewState: ViewState) { + override fun render(viewState: ViewState) { Log.d("###", "ViewState=$viewState") val emailErrorMessage = if (ValidationError.INVALID_EMAIL_ADDRESS in viewState.errors) { @@ -127,8 +130,7 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) { } } - @Suppress("NOTHING_TO_INLINE") - private inline fun intents(): Flow = addBinding.run { + override fun viewIntents(): Flow = addBinding.run { merge( emailEditText .editText!! 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 7448f25c..6fdb2788 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,15 +1,22 @@ package com.hoc.flowmvi.ui.add +import arrow.core.ValidatedNel +import arrow.core.invalidNel import com.hoc.flowmvi.domain.entity.User import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.mvi_base.MviIntent +import com.hoc.flowmvi.mvi_base.MviSingleEvent +import com.hoc.flowmvi.mvi_base.MviViewState -internal enum class ValidationError { +enum class ValidationError { INVALID_EMAIL_ADDRESS, TOO_SHORT_FIRST_NAME, - TOO_SHORT_LAST_NAME + TOO_SHORT_LAST_NAME; + + val asInvalidNel: ValidatedNel = invalidNel() } -internal data class ViewState( +data class ViewState( val errors: Set, val isLoading: Boolean, // @@ -19,13 +26,13 @@ internal data class ViewState( // val email: String?, val firstName: String?, - val lastName: String? -) { + val lastName: String?, +) : MviViewState { companion object { fun initial( email: String?, firstName: String?, - lastName: String? + lastName: String?, ) = ViewState( errors = emptySet(), @@ -40,7 +47,7 @@ internal data class ViewState( } } -internal sealed interface ViewIntent { +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 @@ -102,7 +109,7 @@ internal sealed interface PartialStateChange { } } -internal sealed interface SingleEvent { +sealed interface SingleEvent : MviSingleEvent { data class AddUserSuccess(val user: User) : SingleEvent data class AddUserFailure(val user: User, val error: UserError) : SingleEvent } 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 764ccbf6..5dc1f112 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,18 +3,18 @@ package com.hoc.flowmvi.ui.add import android.util.Log import androidx.core.util.PatternsCompat import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import arrow.core.Either -import arrow.core.identity +import arrow.core.ValidatedNel +import arrow.core.orNull +import arrow.core.validNel +import arrow.core.zip import com.hoc.flowmvi.domain.entity.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase +import com.hoc.flowmvi.mvi_base.BaseMviViewModel import com.hoc081098.flowext.flatMapFirst import com.hoc081098.flowext.withLatestFrom import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -27,23 +27,16 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn @ExperimentalCoroutinesApi internal class AddVM( private val addUser: AddUserUseCase, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - private val _eventChannel = Channel(Channel.BUFFERED) - private val _intentFlow = MutableSharedFlow(extraBufferCapacity = 64) + private val savedStateHandle: SavedStateHandle, +) : BaseMviViewModel() { - val viewState: StateFlow - val singleEvent: Flow get() = _eventChannel.receiveAsFlow() - - fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent) + override val viewState: StateFlow init { val initialVS = ViewState.initial( @@ -51,13 +44,13 @@ internal class AddVM( firstName = savedStateHandle.get(FIRST_NAME_KEY), lastName = savedStateHandle.get(LAST_NAME_KEY), ) - Log.d("###", "[ADD_VM] initialVS: $initialVS") + Log.d(tag, "[ADD_VM] initialVS: $initialVS") - viewState = _intentFlow + viewState = intentFlow .toPartialStateChangesFlow() .sendSingleEvent() .scan(initialVS) { state, change -> change.reduce(state) } - .catch { Log.d("###", "[ADD_VM] Throwable: $it") } + .catch { Log.d(tag, "[ADD_VM] Throwable: $it") } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } @@ -74,64 +67,62 @@ internal class AddVM( 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 + is PartialStateChange.FormValueChange.EmailChanged -> { + savedStateHandle.set(EMAIL_KEY, change.email) + return@onEach + } + is PartialStateChange.FormValueChange.FirstNameChanged -> { + savedStateHandle.set(FIRST_NAME_KEY, change.firstName) + return@onEach + } + is PartialStateChange.FormValueChange.LastNameChanged -> { + savedStateHandle.set(LAST_NAME_KEY, change.lastName) + return@onEach + } } - _eventChannel.send(event) + sendEvent(event) } } private fun Flow.toPartialStateChangesFlow(): Flow { - val emailErrors = filterIsInstance() + val emailFlow = filterIsInstance() + .log("Intent") .map { it.email } .distinctUntilChanged() - .map { validateEmail(it) to it } - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed() - ) + .shareWhileSubscribed() - val firstNameErrors = filterIsInstance() + val firstNameFlow = filterIsInstance() + .log("Intent") .map { it.firstName } .distinctUntilChanged() - .map { validateFirstName(it) to it } - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed() - ) + .shareWhileSubscribed() - val lastNameErrors = filterIsInstance() + val lastNameFlow = filterIsInstance() + .log("Intent") .map { it.lastName } .distinctUntilChanged() - .map { validateLastName(it) to it } - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed() - ) - - val userFormFlow = - combine(emailErrors, firstNameErrors, lastNameErrors) { email, firstName, lastName -> - val errors = email.first + firstName.first + lastName.first - - if (errors.isEmpty()) Either.Right( - User( - firstName = firstName.second!!, - email = email.second!!, - lastName = lastName.second!!, - id = "", - avatar = "" - ) - ) else Either.Left(errors) - } - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed() + .shareWhileSubscribed() + + val userFormFlow = combine( + emailFlow.map { validateEmail(it) }.distinctUntilChanged(), + firstNameFlow.map { validateFirstName(it) }.distinctUntilChanged(), + lastNameFlow.map { validateLastName(it) }.distinctUntilChanged(), + ) { emailValidated, firstNameValidated, lastNameValidated -> + emailValidated.zip(firstNameValidated, lastNameValidated) { email, firstName, lastName -> + User( + firstName = firstName, + email = email, + lastName = lastName, + id = "", + avatar = "" ) + } + }.stateWithInitialNullWhileSubscribed() val addUserChanges = filterIsInstance() + .log("Intent") .withLatestFrom(userFormFlow) { _, userForm -> userForm } - .mapNotNull { it.orNull() } + .mapNotNull { it?.orNull() } .flatMapFirst { user -> flow { emit(addUser(user)) } .map { result -> @@ -145,35 +136,33 @@ internal class AddVM( val firstChanges = merge( filterIsInstance() + .log("Intent") .map { PartialStateChange.FirstChange.EmailChangedFirstTime }, filterIsInstance() + .log("Intent") .map { PartialStateChange.FirstChange.FirstNameChangedFirstTime }, filterIsInstance() + .log("Intent") .map { PartialStateChange.FirstChange.LastNameChangedFirstTime } ) val formValuesChanges = merge( - emailErrors - .map { it.second } - .onEach { savedStateHandle.set(EMAIL_KEY, it) } - .map { PartialStateChange.FormValueChange.EmailChanged(it) }, - firstNameErrors - .map { it.second } - .onEach { savedStateHandle.set(FIRST_NAME_KEY, it) } - .map { PartialStateChange.FormValueChange.FirstNameChanged(it) }, - lastNameErrors - .map { it.second } - .onEach { savedStateHandle.set(LAST_NAME_KEY, it) } - .map { PartialStateChange.FormValueChange.LastNameChanged(it) }, + 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( + validated?.fold( + { it.toSet() }, + { emptySet() } + ) ?: emptySet() + ) + } + return merge( - userFormFlow - .map { - PartialStateChange.ErrorsChanged( - it.fold(::identity) { emptySet() } - ) - }, + errorsChanges, addUserChanges, firstChanges, formValuesChanges, @@ -188,40 +177,28 @@ internal class AddVM( const val MIN_LENGTH_FIRST_NAME = 3 const val MIN_LENGTH_LAST_NAME = 3 - fun validateFirstName(firstName: String?): Set { - val errors = mutableSetOf() - + fun validateFirstName(firstName: String?): ValidatedNel { if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { - errors += ValidationError.TOO_SHORT_FIRST_NAME + return ValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel } - - // more validation here - - return errors + // more validations here + return firstName.validNel() } - fun validateLastName(lastName: String?): Set { - val errors = mutableSetOf() - + fun validateLastName(lastName: String?): ValidatedNel { if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { - errors += ValidationError.TOO_SHORT_LAST_NAME + return ValidationError.TOO_SHORT_LAST_NAME.asInvalidNel } - - // more validation here - - return errors + // more validations here + return lastName.validNel() } - fun validateEmail(email: String?): Set { - val errors = mutableSetOf() - + fun validateEmail(email: String?): ValidatedNel { if (email == null || !PatternsCompat.EMAIL_ADDRESS.matcher(email).matches()) { - errors += ValidationError.INVALID_EMAIL_ADDRESS + return ValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel } - - // more validation here - - return errors + // more validations here + return email.validNel() } } } 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 5c291d67..04042be3 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 @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take @@ -78,7 +77,7 @@ class MainVM( } private fun Flow.toPartialChangeFlow(): Flow = - shareIn(viewModelScope, SharingStarted.WhileSubscribed()).run { + shareWhileSubscribed().run { val getUserChanges = defer(getUsersUseCase::invoke) .onEach { either -> Log.d("###", "[MAIN_VM] Emit users.size=${either.map { it.size }}") } .map { result -> diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index d5bcfc8b..31482058 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -2,13 +2,17 @@ package com.hoc.flowmvi.mvi_base import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel 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 /** * Object that will subscribes to a MviView's [MviIntent]s, @@ -28,18 +32,25 @@ interface MviViewModel { abstract class BaseMviViewModel : MviViewModel, ViewModel() { - private val tag by lazy(LazyThreadSafetyMode.PUBLICATION) { this::class.java.simpleName.take(23) } + protected val tag by lazy(LazyThreadSafetyMode.PUBLICATION) { this::class.java.simpleName.take(23) } private val eventChannel = Channel(Channel.UNLIMITED) private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) - override val singleEvent: Flow get() = eventChannel.receiveAsFlow() - override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) + final override val singleEvent: Flow get() = eventChannel.receiveAsFlow() + final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) protected suspend fun sendEvent(event: E) = eventChannel.send(event) protected val intentFlow: SharedFlow get() = intentMutableFlow - protected fun Flow.log(subject: String) = onEach { Log.d(tag, ">>> $subject: $it") } + protected fun Flow.log(subject: String): Flow = + onEach { Log.d(tag, ">>> $subject: $it") } + + protected fun Flow.shareWhileSubscribed(): SharedFlow = + shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + + protected fun Flow.stateWithInitialNullWhileSubscribed(): StateFlow = + stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) private companion object { /** From 3bc6f23383abf6525a8e1c2273b025f97694ab55 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 21:33:20 +0700 Subject: [PATCH 06/21] refactor AddVM --- .../src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 4 ++-- .../main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 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 5dc1f112..6e617fcb 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,13 @@ internal class AddVM( firstName = savedStateHandle.get(FIRST_NAME_KEY), lastName = savedStateHandle.get(LAST_NAME_KEY), ) - Log.d(tag, "[ADD_VM] initialVS: $initialVS") + Log.d(logTag, "[ADD_VM] initialVS: $initialVS") viewState = intentFlow .toPartialStateChangesFlow() .sendSingleEvent() .scan(initialVS) { state, change -> change.reduce(state) } - .catch { Log.d(tag, "[ADD_VM] Throwable: $it") } + .catch { Log.d(logTag, "[ADD_VM] Throwable: $it") } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index 31482058..8cfe0719 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -32,7 +32,9 @@ interface MviViewModel { abstract class BaseMviViewModel : MviViewModel, ViewModel() { - protected val tag by lazy(LazyThreadSafetyMode.PUBLICATION) { this::class.java.simpleName.take(23) } + protected val logTag by lazy(LazyThreadSafetyMode.PUBLICATION) { + this::class.java.simpleName.take(23) + } private val eventChannel = Channel(Channel.UNLIMITED) private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) @@ -40,11 +42,15 @@ abstract class BaseMviViewModel get() = eventChannel.receiveAsFlow() final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) + // Send event and access intent flow. + protected suspend fun sendEvent(event: E) = eventChannel.send(event) protected val intentFlow: SharedFlow get() = intentMutableFlow + // Extensions on Flow using viewModelScope. + protected fun Flow.log(subject: String): Flow = - onEach { Log.d(tag, ">>> $subject: $it") } + onEach { Log.d(logTag, ">>> $subject: $it") } protected fun Flow.shareWhileSubscribed(): SharedFlow = shareIn(viewModelScope, SharingStarted.WhileSubscribed()) From 7e5ae5f083cb94dd8556d8f132c69942ca7ed1eb Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 21:34:01 +0700 Subject: [PATCH 07/21] split file --- .../hoc/flowmvi/mvi_base/BaseMviViewModel.kt | 56 +++++++++++++++++++ .../com/hoc/flowmvi/mvi_base/MviViewModel.kt | 52 ----------------- 2 files changed, 56 insertions(+), 52 deletions(-) create mode 100644 mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt new file mode 100644 index 00000000..4b22f492 --- /dev/null +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt @@ -0,0 +1,56 @@ +package com.hoc.flowmvi.mvi_base + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +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 + +abstract class BaseMviViewModel : + MviViewModel, ViewModel() { + protected val logTag by lazy(LazyThreadSafetyMode.PUBLICATION) { + this::class.java.simpleName.take(23) + } + + private val eventChannel = Channel(Channel.UNLIMITED) + private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) + + final override val singleEvent: Flow get() = eventChannel.receiveAsFlow() + final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) + + // Send event and access intent flow. + + protected suspend fun sendEvent(event: E) = eventChannel.send(event) + protected val intentFlow: SharedFlow get() = intentMutableFlow + + // Extensions on Flow using viewModelScope. + + protected fun Flow.log(subject: String): Flow = + onEach { Log.d(logTag, ">>> $subject: $it") } + + protected fun 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]. + * If it falls behind by more than 64 state updates, it will start suspending. + * Slow consumers should consider using `stateFlow.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)`. + * + * The internally allocated buffer is replay + extraBufferCapacity but always allocates 2^n space. + * We use replay=0 so buffer = 64. + */ + private const val SubscriberBufferSize = 64 + } +} diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt index 8cfe0719..daa52d4d 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewModel.kt @@ -1,18 +1,7 @@ package com.hoc.flowmvi.mvi_base -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel 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 /** * Object that will subscribes to a MviView's [MviIntent]s, @@ -29,44 +18,3 @@ interface MviViewModel { suspend fun processIntent(intent: I) } - -abstract class BaseMviViewModel : - MviViewModel, ViewModel() { - protected val logTag by lazy(LazyThreadSafetyMode.PUBLICATION) { - this::class.java.simpleName.take(23) - } - - private val eventChannel = Channel(Channel.UNLIMITED) - private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) - - final override val singleEvent: Flow get() = eventChannel.receiveAsFlow() - final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) - - // Send event and access intent flow. - - protected suspend fun sendEvent(event: E) = eventChannel.send(event) - protected val intentFlow: SharedFlow get() = intentMutableFlow - - // Extensions on Flow using viewModelScope. - - protected fun Flow.log(subject: String): Flow = - onEach { Log.d(logTag, ">>> $subject: $it") } - - protected fun 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]. - * If it falls behind by more than 64 state updates, it will start suspending. - * Slow consumers should consider using `stateFlow.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)`. - * - * The internally allocated buffer is replay + extraBufferCapacity but always allocates 2^n space. - * We use replay=0 so buffer = 64. - */ - private const val SubscriberBufferSize = 64 - } -} From 85b048a745bd178170c4fdd8dd7ac9707ee9bd4b Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 21:37:03 +0700 Subject: [PATCH 08/21] update README [skip ci] --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ddc1a5db..686c09de 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ ## Coroutine + Flow = MVI :heart: * Play MVI with Kotlin Coroutines Flow. -* Multiple modules, Clean Architecture. +* Multiple modules, Clean Architecture, Functional programming with [ΛRROW.kt](https://arrow-kt.io/). * Unit tests for MVI ViewModel, domain and data layer. * Master branch using Koin for DI. -* **Checkout [dagger_hilt branch](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/tree/dagger_hilt), using Dagger Hilt for DI** (_obsolete_). +* **Checkout [dagger_hilt branch](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/tree/dagger_hilt), using Dagger Hilt for DI** (_obsolete, will update as soon as possible_). * **[Download latest debug APK here](https://nightly.link/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/workflows/build/master/app-debug.zip)**. > **Jetpack Compose Version** 👉 https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow @@ -35,7 +35,7 @@ This pattern was specified by [André Medeiros (Staltz)](https://twitter.com/and

- + - `intent()`: This function takes the input from the user (i.e. UI events, like click events) and translate it to “something” that will be passed as parameter to `model()` function. This could be a simple string to set a value of the model to or more complex data structure like an Object. We could say we have the intention to change the model with an intent. - `model()`: The `model()` function takes the output from `intent()` as input to manipulate the Model. The output of this function is a new Model (state changed). @@ -48,10 +48,10 @@ This pattern was specified by [André Medeiros (Staltz)](https://twitter.com/and - `view()`: This method takes the model returned from `model()` function and gives it as input to the `view()` function. Then the View simply displays this Model somehow. `view()` is basically the same as `view.render(model)`. ### Reference - + - [Model-View-Intent Design Pattern on Android](https://xizzhu.me/post/2021-06-21-android-mvi-kotlin-coroutines-flow-compose/) - [Reactive Apps with Model-View-Intent](https://hannesdorfmann.com/android/mosby3-mvi-1/) - + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): From bb10d027578b86262e1f3a3441dd9ddaac4b9d3e Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 21:38:23 +0700 Subject: [PATCH 09/21] rename [skip ci] --- feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 4 ++-- feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt | 4 ++-- .../mvi_base/{BaseMviViewModel.kt => AbstractMviViewModel.kt} | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/{BaseMviViewModel.kt => AbstractMviViewModel.kt} (96%) 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 6e617fcb..6085b421 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 @@ -10,7 +10,7 @@ import arrow.core.validNel import arrow.core.zip import com.hoc.flowmvi.domain.entity.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase -import com.hoc.flowmvi.mvi_base.BaseMviViewModel +import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst import com.hoc081098.flowext.withLatestFrom import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.stateIn internal class AddVM( private val addUser: AddUserUseCase, private val savedStateHandle: SavedStateHandle, -) : BaseMviViewModel() { +) : AbstractMviViewModel() { 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 04042be3..e119906c 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 @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase -import com.hoc.flowmvi.mvi_base.BaseMviViewModel +import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -35,7 +35,7 @@ class MainVM( private val getUsersUseCase: GetUsersUseCase, private val refreshGetUsers: RefreshGetUsersUseCase, private val removeUser: RemoveUserUseCase, -) : BaseMviViewModel() { +) : AbstractMviViewModel() { override val viewState: StateFlow diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt similarity index 96% rename from mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt rename to mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt index 4b22f492..c47af2bd 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/BaseMviViewModel.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn -abstract class BaseMviViewModel : +abstract class AbstractMviViewModel : MviViewModel, ViewModel() { protected val logTag by lazy(LazyThreadSafetyMode.PUBLICATION) { this::class.java.simpleName.take(23) From 22f9c56d98c0c78e77491ed59071694af293deef Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 22:19:23 +0700 Subject: [PATCH 10/21] SearchVM.kt --- .../hoc/flowmvi/ui/search/SearchActivity.kt | 68 +++++++++++-------- .../hoc/flowmvi/ui/search/SearchContract.kt | 13 ++-- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 25 ++----- 3 files changed, 53 insertions(+), 53 deletions(-) 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 e66ee252..efd29c15 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 @@ -20,6 +20,7 @@ import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.queryTextEvents import com.hoc.flowmvi.core.toast import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.mvi_base.MviView import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,7 +38,9 @@ import kotlin.time.ExperimentalTime @ExperimentalCoroutinesApi @FlowPreview @ExperimentalTime -class SearchActivity : AppCompatActivity(R.layout.activity_search) { +class SearchActivity : + AppCompatActivity(R.layout.activity_search), + MviView { private val binding by viewBinding() private val vm by viewModel() @@ -53,42 +56,47 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { } private fun bindVM() { - vm.viewState.collectIn(this) { viewState -> - searchAdapter.submitList(viewState.users) - - binding.run { - textQuery.isInvisible = viewState.isLoading || viewState.query.isBlank() - textQuery.text = "Search results for '${viewState.query}'" - - errorGroup.isVisible = viewState.error !== null - errorMessageTextView.text = viewState.error?.let { - when (it) { - is UserError.InvalidId -> "Invalid id" - UserError.NetworkError -> "Network error" - UserError.ServerError -> "Server error" - UserError.Unexpected -> "Unexpected error" - is UserError.UserNotFound -> "User not found" - UserError.ValidationFailed -> "Validation failed" - } - } + vm.viewState + .collectIn(this) { viewState -> render(viewState) } - progressBar.isVisible = viewState.isLoading - } + vm.singleEvent + .collectIn(this) { event -> handleSingleEvent(event) } + + viewIntents() + .onEach { vm.processIntent(it) } + .launchIn(lifecycleScope) + } + + override fun handleSingleEvent(event: SingleEvent) { + when (event) { + is SingleEvent.SearchFailure -> toast("Failed to search") } + } - vm.singleEvent.collectIn(this) { event -> - when (event) { - is SingleEvent.SearchFailure -> toast("Failed to search") + override fun render(viewState: ViewState) { + searchAdapter.submitList(viewState.users) + + binding.run { + textQuery.isInvisible = viewState.isLoading || viewState.query.isBlank() + textQuery.text = "Search results for '${viewState.query}'" + + errorGroup.isVisible = viewState.error !== null + errorMessageTextView.text = viewState.error?.let { + when (it) { + is UserError.InvalidId -> "Invalid id" + UserError.NetworkError -> "Network error" + UserError.ServerError -> "Server error" + UserError.Unexpected -> "Unexpected error" + is UserError.UserNotFound -> "User not found" + UserError.ValidationFailed -> "Validation failed" + } } - } - intents() - .onEach { vm.processIntent(it) } - .launchIn(lifecycleScope) + progressBar.isVisible = viewState.isLoading + } } - @Suppress("NOTHING_TO_INLINE") - private inline fun intents(): Flow = merge( + override fun viewIntents(): Flow = merge( searchViewQueryTextEventChannel .consumeAsFlow() .onEach { Log.d("SearchActivity", "Query $it") } 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 bcd4f5cb..7bca2eac 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 @@ -2,11 +2,14 @@ package com.hoc.flowmvi.ui.search import com.hoc.flowmvi.domain.entity.User import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.mvi_base.MviIntent +import com.hoc.flowmvi.mvi_base.MviSingleEvent +import com.hoc.flowmvi.mvi_base.MviViewState import dev.ahmedmourad.nocopy.annotations.NoCopy @Suppress("DataClassPrivateConstructor") @NoCopy -internal data class UserItem private constructor( +data class UserItem private constructor( val id: String, val email: String, val avatar: String, @@ -24,17 +27,17 @@ internal data class UserItem private constructor( } } -internal sealed interface ViewIntent { +sealed interface ViewIntent : MviIntent { data class Search(val query: String) : ViewIntent object Retry : ViewIntent } -internal data class ViewState( +data class ViewState( val users: List, val isLoading: Boolean, val error: UserError?, val query: String, -) { +) : MviViewState { companion object Factory { fun initial(): ViewState { return ViewState( @@ -69,6 +72,6 @@ internal sealed interface PartialStateChange { } } -internal sealed interface SingleEvent { +sealed interface SingleEvent : MviSingleEvent { data class SearchFailure(val error: UserError) : SingleEvent } 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 88958e70..92ecc1c4 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 @@ -1,16 +1,14 @@ package com.hoc.flowmvi.ui.search import android.util.Log -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase +import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst import com.hoc081098.flowext.takeUntil import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -25,9 +23,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlin.time.Duration import kotlin.time.ExperimentalTime @@ -37,22 +33,18 @@ import kotlin.time.ExperimentalTime @ExperimentalCoroutinesApi internal class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, -) : ViewModel() { - private val _intentFlow = MutableSharedFlow(extraBufferCapacity = 64) - private val _singleEvent = Channel(Channel.BUFFERED) +) : AbstractMviViewModel() { - val viewState: StateFlow - val singleEvent: Flow get() = _singleEvent.receiveAsFlow() - fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent) + override val viewState: StateFlow init { val initialVS = ViewState.initial() - viewState = _intentFlow + viewState = intentFlow .toPartialStateChangesFlow() .sendSingleEvent() .scan(initialVS) { state, change -> change.reduce(state) } - .catch { Log.d("###", "[SEARCH_VM] Throwable: $it") } + .catch { Log.d(logTag, "[SEARCH_VM] Throwable: $it") } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } @@ -78,10 +70,7 @@ internal class SearchVM( .map { it.query } .filter { it.isNotBlank() } .distinctUntilChanged() - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - ) + .shareWhileSubscribed() return merge( queryFlow.flatMapLatest(executeSearch), @@ -98,7 +87,7 @@ internal class SearchVM( private fun Flow.sendSingleEvent(): Flow = onEach { change -> when (change) { - is PartialStateChange.Failure -> _singleEvent.send(SingleEvent.SearchFailure(change.error)) + is PartialStateChange.Failure -> sendEvent(SingleEvent.SearchFailure(change.error)) PartialStateChange.Loading -> return@onEach is PartialStateChange.Success -> return@onEach } From ad5426a52a0137ce60ca61b3d43db4fc89f87ef6 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 22:48:30 +0700 Subject: [PATCH 11/21] AbstractMviActivity --- .../com/hoc/flowmvi/ui/main/MainActivity.kt | 39 ++------------- mvi/mvi-base/build.gradle.kts | 4 ++ .../flowmvi/mvi_base/AbstractMviActivity.kt | 47 +++++++++++++++++++ 3 files changed, 56 insertions(+), 34 deletions(-) create mode 100644 mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt 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 e1a790fa..2b75a8a8 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 @@ -1,24 +1,20 @@ package com.hoc.flowmvi.ui.main -import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.hoc.flowmvi.core.SwipeLeftToDeleteCallback import com.hoc.flowmvi.core.clicks -import com.hoc.flowmvi.core.collectIn import com.hoc.flowmvi.core.navigator.Navigator import com.hoc.flowmvi.core.refreshes import com.hoc.flowmvi.core.toast import com.hoc.flowmvi.domain.repository.UserError -import com.hoc.flowmvi.mvi_base.MviView +import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.main.databinding.ActivityMainBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -27,19 +23,16 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @FlowPreview @ExperimentalCoroutinesApi class MainActivity : - AppCompatActivity(R.layout.activity_main), - MviView { - private val mainVM by viewModel() + AbstractMviActivity(R.layout.activity_main) { + override val vm by viewModel() private val navigator by inject() private val userAdapter = UserAdapter() @@ -47,13 +40,6 @@ class MainActivity : private val removeChannel = Channel(Channel.BUFFERED) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setupViews() - bindVM(mainVM) - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.add_action -> { @@ -71,7 +57,7 @@ class MainActivity : override fun onCreateOptionsMenu(menu: Menu?) = menuInflater.inflate(R.menu.menu_main, menu).let { true } - private fun setupViews() { + override fun setupViews() { mainBinding.usersRecycler.run { setHasFixedSize(true) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) @@ -80,28 +66,13 @@ class MainActivity : ItemTouchHelper( SwipeLeftToDeleteCallback(context) cb@{ position -> - val userItem = mainVM.viewState.value.userItems[position] + val userItem = vm.viewState.value.userItems[position] removeChannel.trySend(userItem) } ).attachToRecyclerView(this) } } - private fun bindVM(mainVM: MainVM) { - // observe view model - mainVM.viewState - .collectIn(this) { render(it) } - - // observe single event - mainVM.singleEvent - .collectIn(this) { handleSingleEvent(it) } - - // pass view intent to view model - viewIntents() - .onEach { mainVM.processIntent(it) } - .launchIn(lifecycleScope) - } - override fun viewIntents(): Flow = merge( flowOf(ViewIntent.Initial), mainBinding.swipeRefreshLayout.refreshes().map { ViewIntent.Refresh }, diff --git a/mvi/mvi-base/build.gradle.kts b/mvi/mvi-base/build.gradle.kts index eaa84e13..fe6f9c55 100644 --- a/mvi/mvi-base/build.gradle.kts +++ b/mvi/mvi-base/build.gradle.kts @@ -32,8 +32,12 @@ android { } dependencies { + implementation(deps.androidx.appCompat) implementation(deps.lifecycle.viewModelKtx) + implementation(deps.lifecycle.runtimeKtx) implementation(deps.coroutines.core) + implementation(core) + addUnitTest() } 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 new file mode 100644 index 00000000..cbb29495 --- /dev/null +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviActivity.kt @@ -0,0 +1,47 @@ +package com.hoc.flowmvi.mvi_base + +import android.os.Bundle +import androidx.annotation.CallSuper +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.hoc.flowmvi.core.collectIn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +abstract class AbstractMviActivity< + I : MviIntent, + S : MviViewState, + E : MviSingleEvent, + VM : MviViewModel, + >( + @LayoutRes contentLayoutId: Int, +) : + AppCompatActivity(contentLayoutId), MviView { + protected abstract val vm: VM + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setupViews() + bindVM() + } + + private fun bindVM() { + // observe view model + vm.viewState + .collectIn(this) { render(it) } + + // observe single event + vm.singleEvent + .collectIn(this) { handleSingleEvent(it) } + + // pass view intent to view model + viewIntents() + .onEach { vm.processIntent(it) } + .launchIn(lifecycleScope) + } + + protected abstract fun setupViews() +} From 23066ffc9990136fd2d918a2ccdf07bbc5629802 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 22:53:22 +0700 Subject: [PATCH 12/21] AbstractMviActivity --- .../com/hoc/flowmvi/ui/add/AddActivity.kt | 42 ++++--------------- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 2 +- 2 files changed, 8 insertions(+), 36 deletions(-) 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 a86bd3c8..338b3a46 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 @@ -2,46 +2,31 @@ package com.hoc.flowmvi.ui.add import android.content.Context import android.content.Intent -import android.os.Bundle import android.util.Log import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isInvisible -import androidx.lifecycle.lifecycleScope import androidx.transition.AutoTransition import androidx.transition.TransitionManager import com.hoc.flowmvi.core.clicks -import com.hoc.flowmvi.core.collectIn import com.hoc.flowmvi.core.firstChange import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.textChanges import com.hoc.flowmvi.core.toast -import com.hoc.flowmvi.mvi_base.MviView +import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.add.databinding.ActivityAddBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach import org.koin.androidx.viewmodel.ext.android.viewModel @ExperimentalCoroutinesApi class AddActivity : - AppCompatActivity(R.layout.activity_add), - MviView { - private val addVM by viewModel() + AbstractMviActivity(R.layout.activity_add) { + override val vm by viewModel() private val addBinding by viewBinding() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - setupViews() - bindVM(addVM) - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> true.also { finish() } @@ -49,21 +34,6 @@ class AddActivity : } } - private fun bindVM(addVM: AddVM) { - // observe view model - addVM.viewState - .collectIn(this) { render(it) } - - // observe single event - addVM.singleEvent - .collectIn(this) { handleSingleEvent(it) } - - // pass view intent to view model - viewIntents() - .onEach { addVM.processIntent(it) } - .launchIn(lifecycleScope) - } - override fun handleSingleEvent(event: SingleEvent) { Log.d("###", "Event=$event") @@ -120,8 +90,10 @@ class AddActivity : addBinding.addButton.isInvisible = viewState.isLoading } - private fun setupViews() { - val state = addVM.viewState.value + override fun setupViews() { + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val state = vm.viewState.value addBinding.run { emailEditText.editText!!.setText(state.email) 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 6085b421..5347ad34 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 @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn @ExperimentalCoroutinesApi -internal class AddVM( +class AddVM( private val addUser: AddUserUseCase, private val savedStateHandle: SavedStateHandle, ) : AbstractMviViewModel() { From b144c1c2d6a4a24fabc45bc8781ec31237ec5de6 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Wed, 3 Nov 2021 22:56:04 +0700 Subject: [PATCH 13/21] AbstractMviActivity --- .../com/hoc/flowmvi/ui/add/AddActivity.kt | 2 +- .../hoc/flowmvi/ui/search/SearchActivity.kt | 34 ++++--------------- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 2 +- 3 files changed, 8 insertions(+), 30 deletions(-) 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 338b3a46..7ca729a5 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 @@ -91,7 +91,7 @@ class AddActivity : } override fun setupViews() { - supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) val state = vm.viewState.value 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 efd29c15..558abac9 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 @@ -3,11 +3,9 @@ package com.hoc.flowmvi.ui.search import android.content.Context import android.content.Intent import android.content.res.Configuration -import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -15,12 +13,11 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.hoc.flowmvi.core.SearchViewQueryTextEvent import com.hoc.flowmvi.core.clicks -import com.hoc.flowmvi.core.collectIn import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.queryTextEvents import com.hoc.flowmvi.core.toast import com.hoc.flowmvi.domain.repository.UserError -import com.hoc.flowmvi.mvi_base.MviView +import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -39,34 +36,13 @@ import kotlin.time.ExperimentalTime @FlowPreview @ExperimentalTime class SearchActivity : - AppCompatActivity(R.layout.activity_search), - MviView { + AbstractMviActivity(R.layout.activity_search) { private val binding by viewBinding() - private val vm by viewModel() + override val vm by viewModel() private val searchViewQueryTextEventChannel = Channel() private val searchAdapter = SearchAdapter() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - - setupViews() - bindVM() - } - - private fun bindVM() { - vm.viewState - .collectIn(this) { viewState -> render(viewState) } - - vm.singleEvent - .collectIn(this) { event -> handleSingleEvent(event) } - - viewIntents() - .onEach { vm.processIntent(it) } - .launchIn(lifecycleScope) - } - override fun handleSingleEvent(event: SingleEvent) { when (event) { is SingleEvent.SearchFailure -> toast("Failed to search") @@ -104,7 +80,9 @@ class SearchActivity : binding.retryButton.clicks().map { ViewIntent.Retry }, ) - private fun setupViews() { + override fun setupViews() { + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + binding.run { usersRecycler.run { setHasFixedSize(true) 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 92ecc1c4..23bbb88c 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 @@ -31,7 +31,7 @@ import kotlin.time.ExperimentalTime @FlowPreview @ExperimentalTime @ExperimentalCoroutinesApi -internal class SearchVM( +class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, ) : AbstractMviViewModel() { From c4c357cabd096aeae67483dabaa7311b0ff9d3f0 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Thu, 4 Nov 2021 02:23:22 +0700 Subject: [PATCH 14/21] Update SearchVM.kt --- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 6 ++-- .../hoc/flowmvi/ui/search/SearchActivity.kt | 29 ++++++++++++------- .../hoc/flowmvi/ui/search/SearchContract.kt | 20 ++++++++----- .../com/hoc/flowmvi/ui/search/SearchModule.kt | 7 ++++- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 24 ++++++++++++--- 5 files changed, 60 insertions(+), 26 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 5347ad34..f8f20198 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 @@ -170,9 +170,9 @@ class AddVM( } private companion object { - const val EMAIL_KEY = "email" - const val FIRST_NAME_KEY = "first_name" - const val LAST_NAME_KEY = "last_name" + const val EMAIL_KEY = "com.hoc.flowmvi.ui.add.email" + const val FIRST_NAME_KEY = "com.hoc.flowmvi.ui.add.first_name" + const val LAST_NAME_KEY = "com.hoc.flowmvi.ui.add.last_name" const val MIN_LENGTH_FIRST_NAME = 3 const val MIN_LENGTH_LAST_NAME = 3 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 558abac9..fa4b6022 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 @@ -53,18 +53,22 @@ class SearchActivity : searchAdapter.submitList(viewState.users) binding.run { - textQuery.isInvisible = viewState.isLoading || viewState.query.isBlank() - textQuery.text = "Search results for '${viewState.query}'" + textQuery.isInvisible = viewState.isLoading || viewState.submittedQuery.isBlank() + if (textQuery.isVisible) { + textQuery.text = "Search results for '${viewState.submittedQuery}'" + } errorGroup.isVisible = viewState.error !== null - errorMessageTextView.text = viewState.error?.let { - when (it) { - is UserError.InvalidId -> "Invalid id" - UserError.NetworkError -> "Network error" - UserError.ServerError -> "Server error" - UserError.Unexpected -> "Unexpected error" - is UserError.UserNotFound -> "User not found" - UserError.ValidationFailed -> "Validation failed" + if (errorGroup.isVisible) { + errorMessageTextView.text = viewState.error?.let { + when (it) { + is UserError.InvalidId -> "Invalid id" + UserError.NetworkError -> "Network error" + UserError.ServerError -> "Server error" + UserError.Unexpected -> "Unexpected error" + is UserError.UserNotFound -> "User not found" + UserError.ValidationFailed -> "Validation failed" + } } } @@ -109,6 +113,11 @@ class SearchActivity : isIconified = false queryHint = "Search user..." + vm.viewState.value + .originalQuery + .takeUnless { it.isNullOrBlank() } + ?.let { setQuery(it, false) } + queryTextEvents() .onEach { searchViewQueryTextEventChannel.send(it) } .launchIn(lifecycleScope) 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 7bca2eac..a7990547 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 @@ -36,15 +36,17 @@ data class ViewState( val users: List, val isLoading: Boolean, val error: UserError?, - val query: String, + val submittedQuery: String, + val originalQuery: String?, ) : MviViewState { companion object Factory { - fun initial(): ViewState { + fun initial(originalQuery: String?): ViewState { return ViewState( users = emptyList(), isLoading = false, error = null, - query = "", + submittedQuery = "", + originalQuery = originalQuery, ) } } @@ -52,14 +54,15 @@ data class ViewState( internal sealed interface PartialStateChange { object Loading : PartialStateChange - data class Success(val users: List, val query: String) : PartialStateChange - data class Failure(val error: UserError, val query: String) : 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 - fun reduce(state: ViewState) = when (this) { + fun reduce(state: ViewState): ViewState = when (this) { is Failure -> state.copy( isLoading = false, error = error, - query = query, + submittedQuery = submittedQuery, users = emptyList() ) Loading -> state.copy(isLoading = true, error = null) @@ -67,8 +70,9 @@ internal sealed interface PartialStateChange { isLoading = false, error = null, users = users, - query = query, + submittedQuery = submittedQuery, ) + is QueryChanged -> state.copy(originalQuery = query) } } 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 1757ccd7..38b1c6d7 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 @@ -13,5 +13,10 @@ import kotlin.time.ExperimentalTime val searchModule = module { single { SearchActivity.IntentProvider() } - viewModel { SearchVM(searchUsersUseCase = get()) } + viewModel { params -> + SearchVM( + searchUsersUseCase = get(), + savedStateHandle = params.get(), + ) + } } 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 23bbb88c..9a7145fd 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 @@ -1,6 +1,7 @@ package com.hoc.flowmvi.ui.search import android.util.Log +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel @@ -33,12 +34,15 @@ import kotlin.time.ExperimentalTime @ExperimentalCoroutinesApi class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, + private val savedStateHandle: SavedStateHandle, ) : AbstractMviViewModel() { override val viewState: StateFlow init { - val initialVS = ViewState.initial() + val initialVS = ViewState.initial( + originalQuery = savedStateHandle.get(QUERY_KEY) + ) viewState = intentFlow .toPartialStateChangesFlow() @@ -66,21 +70,25 @@ class SearchVM( } val queryFlow = filterIsInstance() - .debounce(Duration.milliseconds(400)) .map { it.query } + .shareWhileSubscribed() + + val searchableQueryFlow = queryFlow + .debounce(Duration.milliseconds(400)) .filter { it.isNotBlank() } .distinctUntilChanged() .shareWhileSubscribed() return merge( - queryFlow.flatMapLatest(executeSearch), + searchableQueryFlow.flatMapLatest(executeSearch), filterIsInstance() .flatMapFirst { viewState.value.let { vs -> - if (vs.error !== null) executeSearch(vs.query).takeUntil(queryFlow) + if (vs.error !== null) executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) else emptyFlow() } }, + queryFlow.map { PartialStateChange.QueryChanged(it) }, ) } @@ -90,6 +98,14 @@ class SearchVM( is PartialStateChange.Failure -> sendEvent(SingleEvent.SearchFailure(change.error)) PartialStateChange.Loading -> return@onEach is PartialStateChange.Success -> return@onEach + is PartialStateChange.QueryChanged -> { + savedStateHandle.set(QUERY_KEY, change.query) + return@onEach + } } } + + private companion object { + const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query" + } } From 45e3b54049a21cd7979609ccdbfd2851e691e794 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, 4 Nov 2021 19:38:43 +0700 Subject: [PATCH 15/21] cache --- .github/workflows/build.yml | 12 +++++++++++- .github/workflows/gradle-versions-checker.yml | 10 ++++++++++ .github/workflows/review-suggest.yml | 9 +++++++++ .github/workflows/unit-test.yml | 10 ++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 856c2d43..ad1e48d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,22 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDKd + - name: Set up JDK uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: '11' + - name: Cache gradle, wrapper and buildSrc + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ github.job }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}- + - name: Make gradlew executable run: chmod +x ./gradlew diff --git a/.github/workflows/gradle-versions-checker.yml b/.github/workflows/gradle-versions-checker.yml index 6345b8ba..e6580614 100644 --- a/.github/workflows/gradle-versions-checker.yml +++ b/.github/workflows/gradle-versions-checker.yml @@ -21,6 +21,16 @@ jobs: distribution: 'zulu' java-version: '11' + - name: Cache gradle, wrapper and buildSrc + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ github.job }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}- + - name: Make gradlew executable run: chmod +x ./gradlew diff --git a/.github/workflows/review-suggest.yml b/.github/workflows/review-suggest.yml index 4388e9e1..d06d0ddd 100644 --- a/.github/workflows/review-suggest.yml +++ b/.github/workflows/review-suggest.yml @@ -15,6 +15,15 @@ jobs: with: distribution: 'zulu' java-version: '11' + - name: Cache gradle, wrapper and buildSrc + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ github.job }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}- - name: Make gradlew executable run: chmod +x ./gradlew - run: ./gradlew spotlessKotlinApply diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index df9e4c0d..9ad2da3b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -18,6 +18,16 @@ jobs: distribution: 'zulu' java-version: '11' + - name: Cache gradle, wrapper and buildSrc + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ github.job }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}- + - name: Make gradlew executable run: chmod +x ./gradlew From 8687afb13c070cc993b5434be70ae4a0d5869441 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Thu, 4 Nov 2021 19:49:36 +0700 Subject: [PATCH 16/21] chore: using indexing operator instead of `set` --- feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 6 +++--- .../src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt | 2 +- 2 files changed, 4 insertions(+), 4 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 f8f20198..14793bde 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 @@ -68,15 +68,15 @@ class AddVM( PartialStateChange.FirstChange.FirstNameChangedFirstTime -> return@onEach PartialStateChange.FirstChange.LastNameChangedFirstTime -> return@onEach is PartialStateChange.FormValueChange.EmailChanged -> { - savedStateHandle.set(EMAIL_KEY, change.email) + savedStateHandle[EMAIL_KEY] = change.email return@onEach } is PartialStateChange.FormValueChange.FirstNameChanged -> { - savedStateHandle.set(FIRST_NAME_KEY, change.firstName) + savedStateHandle[FIRST_NAME_KEY] = change.firstName return@onEach } is PartialStateChange.FormValueChange.LastNameChanged -> { - savedStateHandle.set(LAST_NAME_KEY, change.lastName) + savedStateHandle[LAST_NAME_KEY] = change.lastName return@onEach } } 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 9a7145fd..09502905 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 @@ -99,7 +99,7 @@ class SearchVM( PartialStateChange.Loading -> return@onEach is PartialStateChange.Success -> return@onEach is PartialStateChange.QueryChanged -> { - savedStateHandle.set(QUERY_KEY, change.query) + savedStateHandle[QUERY_KEY] = change.query return@onEach } } From bfee0c27eb76430ceeebb7189becfc905ca8cb01 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Thu, 4 Nov 2021 20:13:46 +0700 Subject: [PATCH 17/21] update SearchActivity --- .../hoc/flowmvi/ui/search/SearchActivity.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 fa4b6022..8b35599b 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 @@ -109,18 +109,24 @@ class SearchActivity : override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_search, menu) - (menu.findItem(R.id.action_search)!!.actionView as SearchView).run { - isIconified = false - queryHint = "Search user..." - - vm.viewState.value - .originalQuery - .takeUnless { it.isNullOrBlank() } - ?.let { setQuery(it, false) } - - queryTextEvents() - .onEach { searchViewQueryTextEventChannel.send(it) } - .launchIn(lifecycleScope) + menu.findItem(R.id.action_search)!!.let { menuItem -> + (menuItem.actionView as SearchView).run { + isIconified = false + queryHint = "Search user..." + + vm.viewState.value + .originalQuery + .takeUnless { it.isNullOrBlank() } + ?.let { + menuItem.expandActionView() + setQuery(it, true) + clearFocus() + } + + queryTextEvents() + .onEach { searchViewQueryTextEventChannel.send(it) } + .launchIn(lifecycleScope) + } } return true From 801f32c47a9ea86136375cec593a95f441b875b0 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Thu, 4 Nov 2021 20:38:30 +0700 Subject: [PATCH 18/21] fix SearchActivity --- .../hoc/flowmvi/ui/search/SearchActivity.kt | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 8b35599b..3cfe489e 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 @@ -16,6 +16,7 @@ import com.hoc.flowmvi.core.clicks import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.queryTextEvents import com.hoc.flowmvi.core.toast +import com.hoc.flowmvi.core.unit import com.hoc.flowmvi.domain.repository.UserError import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding @@ -114,18 +115,24 @@ class SearchActivity : isIconified = false queryHint = "Search user..." + fun listen() = queryTextEvents() + .onEach { searchViewQueryTextEventChannel.send(it) } + .launchIn(lifecycleScope) + .unit + vm.viewState.value .originalQuery .takeUnless { it.isNullOrBlank() } ?.let { - menuItem.expandActionView() - setQuery(it, true) - clearFocus() - } + post { + menuItem.expandActionView() + setQuery(it, true) + clearFocus() - queryTextEvents() - .onEach { searchViewQueryTextEventChannel.send(it) } - .launchIn(lifecycleScope) + listen() + } + } + ?: listen() } } From 74e67f82d6f94480f2b4e7b6b11c4271d518a448 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Fri, 5 Nov 2021 00:07:28 +0700 Subject: [PATCH 19/21] fix SearchActivity --- core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt | 1 - .../src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 10 ++++++---- .../java/com/hoc/flowmvi/ui/search/SearchContract.kt | 2 +- .../main/java/com/hoc/flowmvi/ui/search/SearchVM.kt | 4 +++- feature-search/src/main/res/menu/menu_search.xml | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt index 9b7903c2..4d2cccf5 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt @@ -118,7 +118,6 @@ fun EditText.textChanges(): Flow { checkMainThread() val listener = doOnTextChanged { text, _, _, _ -> trySend(text) } - addTextChangedListener(listener) awaitClose { removeTextChangedListener(listener) } }.onStart { emit(text) } } 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 14793bde..67d5616d 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 @@ -12,9 +12,11 @@ import com.hoc.flowmvi.domain.entity.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst +import com.hoc081098.flowext.mapTo import com.hoc081098.flowext.withLatestFrom import kotlinx.coroutines.ExperimentalCoroutinesApi 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 @@ -84,7 +86,7 @@ class AddVM( } } - private fun Flow.toPartialStateChangesFlow(): Flow { + private fun SharedFlow.toPartialStateChangesFlow(): Flow { val emailFlow = filterIsInstance() .log("Intent") .map { it.email } @@ -137,13 +139,13 @@ class AddVM( val firstChanges = merge( filterIsInstance() .log("Intent") - .map { PartialStateChange.FirstChange.EmailChangedFirstTime }, + .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), filterIsInstance() .log("Intent") - .map { PartialStateChange.FirstChange.FirstNameChangedFirstTime }, + .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), filterIsInstance() .log("Intent") - .map { PartialStateChange.FirstChange.LastNameChangedFirstTime } + .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) ) val formValuesChanges = merge( 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 a7990547..9c0d3316 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 @@ -72,7 +72,7 @@ internal sealed interface PartialStateChange { users = users, submittedQuery = submittedQuery, ) - is QueryChanged -> state.copy(originalQuery = query) + is QueryChanged -> 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 09502905..278f7955 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 @@ -10,6 +10,7 @@ import com.hoc081098.flowext.takeUntil 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.catch @@ -52,7 +53,7 @@ class SearchVM( .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } - private fun Flow.toPartialStateChangesFlow(): Flow { + private fun SharedFlow.toPartialStateChangesFlow(): Flow { val executeSearch: suspend (String) -> Flow = { query: String -> flow { emit(searchUsersUseCase(query)) } .map { result -> @@ -70,6 +71,7 @@ class SearchVM( } val queryFlow = filterIsInstance() + .log("Intent") .map { it.query } .shareWhileSubscribed() diff --git a/feature-search/src/main/res/menu/menu_search.xml b/feature-search/src/main/res/menu/menu_search.xml index 2f131243..30027c2d 100644 --- a/feature-search/src/main/res/menu/menu_search.xml +++ b/feature-search/src/main/res/menu/menu_search.xml @@ -7,5 +7,5 @@ android:icon="@drawable/ic_baseline_search_24" android:title="Search" app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> + app:showAsAction="always" /> From b3346d969b4ee89ae92b98f8b2e3ccdf58c08065 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Fri, 5 Nov 2021 00:17:59 +0700 Subject: [PATCH 20/21] some fixes --- .../com/hoc/flowmvi/ui/add/AddContract.kt | 16 ++++++++++---- .../hoc/flowmvi/ui/search/SearchActivity.kt | 21 +++++++------------ .../hoc/flowmvi/ui/search/SearchContract.kt | 5 ++++- 3 files changed, 23 insertions(+), 19 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 6fdb2788..c65c0ef7 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 @@ -35,7 +35,6 @@ data class ViewState( lastName: String?, ) = ViewState( errors = emptySet(), - isLoading = false, emailChanged = false, firstNameChanged = false, @@ -97,9 +96,18 @@ internal sealed interface PartialStateChange { sealed class FormValueChange : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { - is EmailChanged -> viewState.copy(email = email) - is FirstNameChanged -> viewState.copy(firstName = firstName) - is LastNameChanged -> viewState.copy(lastName = lastName) + 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) + } } } 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 3cfe489e..8b35599b 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 @@ -16,7 +16,6 @@ import com.hoc.flowmvi.core.clicks import com.hoc.flowmvi.core.navigator.IntentProviders import com.hoc.flowmvi.core.queryTextEvents import com.hoc.flowmvi.core.toast -import com.hoc.flowmvi.core.unit import com.hoc.flowmvi.domain.repository.UserError import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding @@ -115,24 +114,18 @@ class SearchActivity : isIconified = false queryHint = "Search user..." - fun listen() = queryTextEvents() - .onEach { searchViewQueryTextEventChannel.send(it) } - .launchIn(lifecycleScope) - .unit - vm.viewState.value .originalQuery .takeUnless { it.isNullOrBlank() } ?.let { - post { - menuItem.expandActionView() - setQuery(it, true) - clearFocus() - - listen() - } + menuItem.expandActionView() + setQuery(it, true) + clearFocus() } - ?: listen() + + queryTextEvents() + .onEach { searchViewQueryTextEventChannel.send(it) } + .launchIn(lifecycleScope) } } 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 9c0d3316..6c3f3d35 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 @@ -72,7 +72,10 @@ internal sealed interface PartialStateChange { users = users, submittedQuery = submittedQuery, ) - is QueryChanged -> if (state.originalQuery == query) state else state.copy(originalQuery = query) + is QueryChanged -> { + if (state.originalQuery == query) state + else state.copy(originalQuery = query) + } } } From 4a9f804cb8afd49dba4b47e67f92eae8c7881ce1 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Fri, 5 Nov 2021 00:24:21 +0700 Subject: [PATCH 21/21] some fixes --- .../main/java/com/hoc/flowmvi/ui/search/SearchContract.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 6c3f3d35..d970eb30 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 @@ -65,7 +65,11 @@ internal sealed interface PartialStateChange { submittedQuery = submittedQuery, users = emptyList() ) - Loading -> state.copy(isLoading = true, error = null) + Loading -> state.copy( + isLoading = true, + error = null, + users = emptyList() + ) is Success -> state.copy( isLoading = false, error = null,