Skip to content

AbstractMviViewModel and AbstractMviActivity #54

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Nov 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/gradle-versions-checker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/review-suggest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,7 +35,7 @@ This pattern was specified by [André Medeiros (Staltz)](https://twitter.com/and
<p align="center">
<img src="MVI_diagram.png">
<p>

- `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).
Expand All @@ -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)):
Expand Down
1 change: 0 additions & 1 deletion core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ fun EditText.textChanges(): Flow<CharSequence?> {
checkMainThread()

val listener = doOnTextChanged { text, _, _, _ -> trySend(text) }
addTextChangedListener(listener)
awaitClose { removeTextChangedListener(listener) }
}.onStart { emit(text) }
}
Expand Down
48 changes: 11 additions & 37 deletions feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,39 @@ 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<AddVM>()
class AddActivity :
AbstractMviActivity<ViewIntent, ViewState, SingleEvent, AddVM>(R.layout.activity_add) {
override val vm by viewModel<AddVM>()
private val addBinding by viewBinding<ActivityAddBinding>()

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() }
else -> super.onOptionsItemSelected(item)
}
}

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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -127,8 +102,7 @@ class AddActivity : AppCompatActivity(R.layout.activity_add) {
}
}

@Suppress("NOTHING_TO_INLINE")
private inline fun intents(): Flow<ViewIntent> = addBinding.run {
override fun viewIntents(): Flow<ViewIntent> = addBinding.run {
merge(
emailEditText
.editText!!
Expand Down
39 changes: 27 additions & 12 deletions feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
Original file line number Diff line number Diff line change
@@ -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<ValidationError, Nothing> = invalidNel()
}

internal data class ViewState(
data class ViewState(
val errors: Set<ValidationError>,
val isLoading: Boolean,
//
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -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
}
Loading