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 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)): 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/AddActivity.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt index b33fc592..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 @@ -2,43 +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.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) { - private val addVM by viewModel() +class AddActivity : + 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() } @@ -46,22 +34,7 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) { } } - 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 - intents() - .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 +49,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) { @@ -117,8 +90,10 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) { 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) @@ -127,8 +102,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..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 @@ -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,16 +26,15 @@ 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(), - isLoading = false, emailChanged = false, firstNameChanged = false, @@ -40,7 +46,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 @@ -90,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) + } } } @@ -102,7 +117,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..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 @@ -3,18 +3,20 @@ 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.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst +import com.hoc081098.flowext.mapTo 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.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -27,23 +29,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( +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, +) : AbstractMviViewModel() { - 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 +46,13 @@ internal class AddVM( firstName = savedStateHandle.get(FIRST_NAME_KEY), lastName = savedStateHandle.get(LAST_NAME_KEY), ) - Log.d("###", "[ADD_VM] initialVS: $initialVS") + Log.d(logTag, "[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(logTag, "[ADD_VM] Throwable: $it") } .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) } @@ -74,64 +69,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[EMAIL_KEY] = change.email + return@onEach + } + is PartialStateChange.FormValueChange.FirstNameChanged -> { + savedStateHandle[FIRST_NAME_KEY] = change.firstName + return@onEach + } + is PartialStateChange.FormValueChange.LastNameChanged -> { + savedStateHandle[LAST_NAME_KEY] = change.lastName + return@onEach + } } - _eventChannel.send(event) + sendEvent(event) } } - private fun Flow.toPartialStateChangesFlow(): Flow { - val emailErrors = filterIsInstance() + private fun SharedFlow.toPartialStateChangesFlow(): Flow { + 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 +138,33 @@ internal class AddVM( val firstChanges = merge( filterIsInstance() - .map { PartialStateChange.FirstChange.EmailChangedFirstTime }, + .log("Intent") + .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), filterIsInstance() - .map { PartialStateChange.FirstChange.FirstNameChangedFirstTime }, + .log("Intent") + .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), filterIsInstance() - .map { PartialStateChange.FirstChange.LastNameChangedFirstTime } + .log("Intent") + .mapTo(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, @@ -181,47 +172,35 @@ internal 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 - 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/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/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..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 @@ -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.AbstractMviViewModel 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,9 +25,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 kotlinx.coroutines.flow.take @@ -41,20 +35,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) +) : AbstractMviViewModel() { 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,12 +72,12 @@ class MainVM( is PartialChange.GetUser.Data -> return@onEach PartialChange.Refresh.Loading -> return@onEach } - _eventChannel.send(event) + sendEvent(event) } } 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 -> @@ -110,18 +100,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 +129,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/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..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 @@ -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,11 +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.AbstractMviActivity import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding import com.hoc081098.viewbindingdelegate.viewBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,30 +35,31 @@ import kotlin.time.ExperimentalTime @ExperimentalCoroutinesApi @FlowPreview @ExperimentalTime -class SearchActivity : AppCompatActivity(R.layout.activity_search) { +class SearchActivity : + 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() + override fun handleSingleEvent(event: SingleEvent) { + when (event) { + is SingleEvent.SearchFailure -> toast("Failed to search") + } } - private fun bindVM() { - vm.viewState.collectIn(this) { viewState -> - searchAdapter.submitList(viewState.users) + 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}'" + binding.run { + textQuery.isInvisible = viewState.isLoading || viewState.submittedQuery.isBlank() + if (textQuery.isVisible) { + textQuery.text = "Search results for '${viewState.submittedQuery}'" + } - errorGroup.isVisible = viewState.error !== null + errorGroup.isVisible = viewState.error !== null + if (errorGroup.isVisible) { errorMessageTextView.text = viewState.error?.let { when (it) { is UserError.InvalidId -> "Invalid id" @@ -71,24 +70,13 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { UserError.ValidationFailed -> "Validation failed" } } - - progressBar.isVisible = viewState.isLoading } - } - vm.singleEvent.collectIn(this) { event -> - when (event) { - is SingleEvent.SearchFailure -> toast("Failed to search") - } + progressBar.isVisible = viewState.isLoading } - - intents() - .onEach { vm.processIntent(it) } - .launchIn(lifecycleScope) } - @Suppress("NOTHING_TO_INLINE") - private inline fun intents(): Flow = merge( + override fun viewIntents(): Flow = merge( searchViewQueryTextEventChannel .consumeAsFlow() .onEach { Log.d("SearchActivity", "Query $it") } @@ -96,7 +84,9 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { binding.retryButton.clicks().map { ViewIntent.Retry }, ) - private fun setupViews() { + override fun setupViews() { + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + binding.run { usersRecycler.run { setHasFixedSize(true) @@ -119,13 +109,24 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { 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..." + 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) + queryTextEvents() + .onEach { searchViewQueryTextEventChannel.send(it) } + .launchIn(lifecycleScope) + } } return 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 bcd4f5cb..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 @@ -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,24 +27,26 @@ 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, -) { + 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, ) } } @@ -49,26 +54,35 @@ internal 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, users = emptyList() ) - Loading -> state.copy(isLoading = true, error = null) is Success -> state.copy( isLoading = false, error = null, users = users, - query = query, + submittedQuery = submittedQuery, ) + is QueryChanged -> { + if (state.originalQuery == query) state + else state.copy(originalQuery = query) + } } } -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/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 88958e70..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 @@ -1,16 +1,16 @@ package com.hoc.flowmvi.ui.search import android.util.Log -import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle 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.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -25,9 +25,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 @@ -35,28 +33,27 @@ import kotlin.time.ExperimentalTime @FlowPreview @ExperimentalTime @ExperimentalCoroutinesApi -internal class SearchVM( +class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, -) : ViewModel() { - private val _intentFlow = MutableSharedFlow(extraBufferCapacity = 64) - private val _singleEvent = Channel(Channel.BUFFERED) + private val savedStateHandle: SavedStateHandle, +) : 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() + val initialVS = ViewState.initial( + originalQuery = savedStateHandle.get(QUERY_KEY) + ) - 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) } - private fun Flow.toPartialStateChangesFlow(): Flow { + private fun SharedFlow.toPartialStateChangesFlow(): Flow { val executeSearch: suspend (String) -> Flow = { query: String -> flow { emit(searchUsersUseCase(query)) } .map { result -> @@ -74,33 +71,43 @@ internal class SearchVM( } val queryFlow = filterIsInstance() - .debounce(Duration.milliseconds(400)) + .log("Intent") .map { it.query } + .shareWhileSubscribed() + + val searchableQueryFlow = queryFlow + .debounce(Duration.milliseconds(400)) .filter { it.isNotBlank() } .distinctUntilChanged() - .shareIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - ) + .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) }, ) } 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 + is PartialStateChange.QueryChanged -> { + savedStateHandle[QUERY_KEY] = change.query + return@onEach + } } } + + private companion object { + const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query" + } } 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" /> 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() +} 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 new file mode 100644 index 00000000..c47af2bd --- /dev/null +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.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 AbstractMviViewModel : + 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 cf046f03..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 @@ -16,5 +16,5 @@ interface MviViewModel { val singleEvent: Flow - fun processIntent(intent: I) + suspend fun processIntent(intent: I) }