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)
}