diff --git a/.editorconfig b/.editorconfig
index 129c9dcd..4bda7f7f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,11 +1,14 @@
root = true
[*]
-indent_size=2
-end_of_line=lf
-charset=utf-8
-trim_trailing_whitespace=true
-insert_final_newline=true
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
[*.{kt,kts}]
-ij_kotlin_imports_layout=*
+ij_kotlin_imports_layout = *
+
+[*.xml]
+indent_size = 4
diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml
index 5d802526..13ff0873 100644
--- a/.github/workflows/qodana.yml
+++ b/.github/workflows/qodana.yml
@@ -1,32 +1,28 @@
-name: Qodana
-
-on:
- push:
- branches: [ master ]
- paths-ignore: [ '**.md', '**.MD' ]
- pull_request:
- branches: [ master ]
- paths-ignore: [ '**.md', '**.MD' ]
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
-
- - name: Cache Qodana dependencies
- uses: actions/cache@v2
- with:
- path: ~/work/_temp/_github_home/qodana-cache
- key: ${{ runner.os }}-qodana-${{ github.ref }}
- restore-keys: |
- ${{ runner.os }}-qodana-${{ github.ref }}
- ${{ runner.os }}-qodana-
- - uses: JetBrains/qodana-action@v3.2.1
- with:
- linter: qodana-jvm-community
- fail-threshold: 10
-
- - uses: actions/upload-artifact@v2
- with:
- path: ${{ github.workspace }}/qodana
+name: Qodana
+
+on:
+ push:
+ branches: [ master ]
+ paths-ignore: [ '**.md', '**.MD' ]
+ pull_request:
+ branches: [ master ]
+ paths-ignore: [ '**.md', '**.MD' ]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - uses: JetBrains/qodana-action@v4.2.3
+ with:
+ linter: jetbrains/qodana-jvm-android:latest
+ fail-threshold: 10
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ${{ runner.temp }}/qodana/results/report
+ destination_dir: ./
diff --git a/.gitignore b/.gitignore
index a26b43b5..b10b0d06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,8 @@
/.idea/assetWizardSettings.xml
.DS_Store
/build
-buildSrc/build
/captures
.externalNativeBuild
.cxx
+local.properties
+**/build/
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 7bc1015c..d88f58a9 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,7 +1,7 @@
-
+
@@ -11,6 +11,8 @@
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index b9c4f02c..cb99164d 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -25,8 +25,7 @@
-
-
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..0b18bb59
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 72be2440..bd2b2cb7 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -10,10 +10,19 @@
+
+
+
+
+
+
+
+
+
-
+
diff --git a/LICENSE b/LICENSE
index 1af01810..7ca27143 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2019-2021 Kotlin Android Open Source
+Copyright (c) 2019-2022 Kotlin Android Open Source
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index aed46af6..f221ba6d 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,10 @@
[](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/qodana.yml)
[](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/gradle-wrapper-validation.yml)
[](https://android-arsenal.com/api?level=21)
-[](http://kotlinlang.org)
+[](http://kotlinlang.org)
[](https://hits.seeyoufarm.com)
[](https://opensource.org/licenses/MIT)
+[](https://gitter.im/Kotlin-Android-Open-Source/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
## Coroutine + Flow = MVI :heart:
* Play MVI with Kotlin Coroutines Flow.
@@ -23,13 +24,27 @@
> **Jetpack Compose Version** 👉 https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow
+> **Pagination Horizontal List in Vertical List** 👉 https://github.com/Kotlin-Android-Open-Source/Pagination-MVI-Flow
+
+### Light theme
+
+| List view state | Error view state |
+| --------------- | ---------------- |
+|
|
|
+
+| Add new user | Search user |
+| ------------ | ------------ |
+|
|
|
+
+### Dark theme
+
| List view state | Error view state |
| --------------- | ---------------- |
-|
|
|
+|
|
|
| Add new user | Search user |
| ------------ | ------------ |
-|
|
|
+|
|
|
diff --git a/Screenshot_01.png b/Screenshot_01.png
deleted file mode 100644
index 744502a4..00000000
Binary files a/Screenshot_01.png and /dev/null differ
diff --git a/Screenshot_02.png b/Screenshot_02.png
deleted file mode 100644
index bb106327..00000000
Binary files a/Screenshot_02.png and /dev/null differ
diff --git a/Screenshot_03.png b/Screenshot_03.png
deleted file mode 100644
index a2f71da7..00000000
Binary files a/Screenshot_03.png and /dev/null differ
diff --git a/Screenshot_04.png b/Screenshot_04.png
deleted file mode 100644
index 97bb6000..00000000
Binary files a/Screenshot_04.png and /dev/null differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6d8e5bc6..67d9a741 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -34,10 +34,10 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
buildFeatures { viewBinding = true }
testOptions {
@@ -66,14 +66,17 @@ dependencies {
implementation(deps.coroutines.android)
implementation(deps.koin.android)
+ implementation(deps.androidx.material)
debugImplementation(deps.squareup.leakCanary)
implementation(deps.timber)
testImplementation(deps.test.junit)
- androidTestImplementation(deps.test.androidxJunit)
- androidTestImplementation(deps.test.androidXSspresso)
+ androidTestImplementation(deps.test.androidx.junit)
+ androidTestImplementation(deps.test.androidx.core)
+ androidTestImplementation(deps.test.androidx.espresso.core)
addUnitTest()
+ testImplementation(testUtils)
testImplementation(deps.koin.testJunit4)
}
diff --git a/app/release/app-release.apk b/app/release/app-release.apk
deleted file mode 100644
index 090c3d46..00000000
Binary files a/app/release/app-release.apk and /dev/null differ
diff --git a/app/src/main/java/com/hoc/flowmvi/App.kt b/app/src/main/java/com/hoc/flowmvi/App.kt
index f703fc24..27bd8819 100644
--- a/app/src/main/java/com/hoc/flowmvi/App.kt
+++ b/app/src/main/java/com/hoc/flowmvi/App.kt
@@ -1,6 +1,7 @@
package com.hoc.flowmvi
import android.app.Application
+import com.google.android.material.color.DynamicColors
import com.hoc.flowmvi.core.coreModule
import com.hoc.flowmvi.data.dataModule
import com.hoc.flowmvi.domain.domainModule
@@ -20,6 +21,7 @@ import kotlin.time.ExperimentalTime
@ExperimentalCoroutinesApi
@ExperimentalStdlibApi
@ExperimentalTime
+@JvmField
val allModules = listOf(
coreModule,
dataModule,
@@ -38,6 +40,8 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
@@ -47,7 +51,8 @@ class App : Application() {
startKoin {
androidContext(this@App)
- androidLogger(if (BuildConfig.DEBUG) Level.DEBUG else Level.NONE)
+ // TODO(koin): https://github.com/InsertKoinIO/koin/issues/1188
+ androidLogger(if (BuildConfig.DEBUG) Level.ERROR else Level.NONE)
modules(allModules)
}
diff --git a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
index bacf7866..93ac93a3 100644
--- a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
+++ b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
@@ -4,6 +4,7 @@ import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.core_ui.navigator.Navigator
import org.koin.dsl.module
+@JvmField
val coreModule = module {
single { DefaultCoroutineDispatchers() }
diff --git a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt
index fae6bb64..921dfa51 100644
--- a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt
+++ b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt
@@ -1,13 +1,16 @@
package com.hoc.flowmvi
import androidx.lifecycle.SavedStateHandle
+import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule
import io.mockk.every
import io.mockk.mockkClass
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Rule
+import org.koin.core.logger.Level
+import org.koin.dsl.koinApplication
import org.koin.test.AutoCloseKoinTest
-import org.koin.test.check.checkKoinModules
+import org.koin.test.check.checkModules
import org.koin.test.mock.MockProviderRule
import kotlin.test.Test
import kotlin.time.ExperimentalTime
@@ -25,11 +28,20 @@ class CheckModulesTest : AutoCloseKoinTest() {
}
}
}
+ @get:Rule
+ val coroutineRule = TestCoroutineDispatcherRule()
@Test
fun verifyKoinApp() {
- checkKoinModules(allModules) {
- withInstance()
+ koinApplication {
+ modules(allModules)
+
+ // TODO(koin): https://github.com/InsertKoinIO/koin/issues/1188
+ printLogger(Level.ERROR)
+
+ checkModules {
+ withInstance()
+ }
}
}
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 2fd3cbf7..ddbc1b89 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,13 +13,13 @@ buildscript {
maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
}
dependencies {
- classpath("com.android.tools.build:gradle:7.0.3")
+ classpath("com.android.tools.build:gradle:7.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
- classpath("com.diffplug.spotless:spotless-plugin-gradle:6.0.0")
+ classpath("com.diffplug.spotless:spotless-plugin-gradle:6.2.0")
classpath("dev.ahmedmourad.nocopy:nocopy-gradle-plugin:1.4.0")
classpath("org.jacoco:org.jacoco.core:0.8.7")
classpath("com.vanniktech:gradle-android-junit-jacoco-plugin:0.17.0-SNAPSHOT")
- classpath("com.github.ben-manes:gradle-versions-plugin:0.39.0")
+ classpath("com.github.ben-manes:gradle-versions-plugin:0.41.0")
}
}
@@ -112,7 +112,7 @@ subprojects {
allprojects {
tasks.withType {
kotlinOptions {
- val version = JavaVersion.VERSION_1_8.toString()
+ val version = JavaVersion.VERSION_11.toString()
jvmTarget = version
sourceCompatibility = version
targetCompatibility = version
diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore
new file mode 100644
index 00000000..ca730c42
--- /dev/null
+++ b/buildSrc/.gitignore
@@ -0,0 +1,2 @@
+/build
+.gradle
diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt
index 0a8742b3..70c30cdf 100644
--- a/buildSrc/src/main/kotlin/deps.kt
+++ b/buildSrc/src/main/kotlin/deps.kt
@@ -6,8 +6,8 @@ import org.gradle.kotlin.dsl.project
import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec
-const val ktlintVersion = "0.43.0"
-const val kotlinVersion = "1.5.31"
+const val ktlintVersion = "0.43.2"
+const val kotlinVersion = "1.6.10"
object appConfig {
const val applicationId = "com.hoc.flowmvi"
@@ -19,7 +19,7 @@ object appConfig {
const val targetSdkVersion = 31
private const val MAJOR = 2
- private const val MINOR = 0
+ private const val MINOR = 1
private const val PATCH = 0
const val versionCode = MAJOR * 10000 + MINOR * 100 + PATCH
const val versionName = "$MAJOR.$MINOR.$PATCH"
@@ -27,12 +27,12 @@ object appConfig {
object deps {
object androidx {
- const val appCompat = "androidx.appcompat:appcompat:1.4.0"
+ const val appCompat = "androidx.appcompat:appcompat:1.4.1"
const val coreKtx = "androidx.core:core-ktx:1.7.0"
const val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.1"
const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1"
const val swipeRefreshLayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
- const val material = "com.google.android.material:material:1.4.0"
+ const val material = "com.google.android.material:material:1.6.0-alpha02"
}
object lifecycle {
@@ -52,7 +52,7 @@ object deps {
}
object coroutines {
- private const val version = "1.5.2"
+ private const val version = "1.6.0"
const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
@@ -60,7 +60,7 @@ object deps {
}
object koin {
- private const val version = "3.1.3"
+ private const val version = "3.1.5"
const val core = "io.insert-koin:koin-core:$version"
const val android = "io.insert-koin:koin-android:$version"
@@ -68,8 +68,8 @@ object deps {
}
const val coil = "io.coil-kt:coil:1.2.1"
- const val viewBindingDelegate = "com.github.hoc081098:ViewBindingDelegate:1.2.0"
- const val flowExt = "io.github.hoc081098:FlowExt:0.1.0"
+ const val viewBindingDelegate = "com.github.hoc081098:ViewBindingDelegate:1.3.1"
+ const val flowExt = "io.github.hoc081098:FlowExt:0.2.0"
const val timber = "com.jakewharton.timber:timber:5.0.1"
object arrow {
@@ -79,8 +79,15 @@ object deps {
object test {
const val junit = "junit:junit:4.13.2"
- const val androidxJunit = "androidx.test.ext:junit:1.1.2"
- const val androidXSspresso = "androidx.test.espresso:espresso-core:3.3.0"
+
+ object androidx {
+ const val core = "androidx.test:core-ktx:1.4.0"
+ const val junit = "androidx.test.ext:junit-ktx:1.1.3"
+
+ object espresso {
+ const val core = "androidx.test.espresso:espresso-core:3.4.0"
+ }
+ }
const val mockk = "io.mockk:mockk:1.12.1"
const val kotlinJUnit = "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion"
diff --git a/core-ui/build.gradle.kts b/core-ui/build.gradle.kts
index 019ff854..c98b76d2 100644
--- a/core-ui/build.gradle.kts
+++ b/core-ui/build.gradle.kts
@@ -26,10 +26,10 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
testOptions {
unitTests.isIncludeAndroidResources = true
diff --git a/core-ui/src/main/AndroidManifest.xml b/core-ui/src/main/AndroidManifest.xml
index 069f7323..6ae8afba 100644
--- a/core-ui/src/main/AndroidManifest.xml
+++ b/core-ui/src/main/AndroidManifest.xml
@@ -1,13 +1,13 @@
+ package="com.hoc.flowmvi.core_ui">
-
+
-
+
-
+
diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/ContextExtensions.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/ContextExtensions.kt
new file mode 100644
index 00000000..6f559a20
--- /dev/null
+++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/ContextExtensions.kt
@@ -0,0 +1,12 @@
+package com.hoc.flowmvi.core_ui
+
+import android.content.Context
+import android.widget.Toast
+import androidx.annotation.Px
+
+@Suppress("NOTHING_TO_INLINE")
+inline fun Context.toast(text: CharSequence) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
+
+@Px
+@Suppress("NOTHING_TO_INLINE")
+inline fun Context.dpToPx(dp: Float): Int = (dp * resources.displayMetrics.density).toInt()
diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/FlowBinding.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/FlowBinding.kt
index 73d57c17..83d357ae 100644
--- a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/FlowBinding.kt
+++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/FlowBinding.kt
@@ -1,16 +1,13 @@
package com.hoc.flowmvi.core_ui
-import android.content.Context
import android.os.Looper
import android.view.View
import android.widget.EditText
-import android.widget.Toast
import androidx.annotation.CheckResult
import androidx.appcompat.widget.SearchView
import androidx.core.widget.doOnTextChanged
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -25,7 +22,6 @@ internal fun checkMainThread() {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun EditText.firstChange(): Flow {
return callbackFlow {
@@ -41,7 +37,6 @@ fun EditText.firstChange(): Flow {
}.take(1)
}
-@ExperimentalCoroutinesApi
@CheckResult
fun SwipeRefreshLayout.refreshes(): Flow {
return callbackFlow {
@@ -52,7 +47,6 @@ fun SwipeRefreshLayout.refreshes(): Flow {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun View.clicks(): Flow {
return callbackFlow {
@@ -69,7 +63,6 @@ data class SearchViewQueryTextEvent(
val isSubmitted: Boolean,
)
-@ExperimentalCoroutinesApi
@CheckResult
fun SearchView.queryTextEvents(): Flow {
return callbackFlow {
@@ -111,7 +104,6 @@ fun SearchView.queryTextEvents(): Flow {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun EditText.textChanges(): Flow {
return callbackFlow {
@@ -121,5 +113,3 @@ fun EditText.textChanges(): Flow {
awaitClose { removeTextChangedListener(listener) }
}.onStart { emit(text) }
}
-
-fun Context.toast(text: CharSequence) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/SwipeLeftToDeleteCallback.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/SwipeLeftToDeleteCallback.kt
index 71ae822e..39b5ad6b 100644
--- a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/SwipeLeftToDeleteCallback.kt
+++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/SwipeLeftToDeleteCallback.kt
@@ -2,22 +2,26 @@ package com.hoc.flowmvi.core_ui
import android.content.Context
import android.graphics.Canvas
-import android.graphics.Color
import android.graphics.drawable.ColorDrawable
-import androidx.core.content.ContextCompat
+import androidx.core.content.ContextCompat.getColor
+import androidx.core.content.ContextCompat.getDrawable
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
+import kotlin.LazyThreadSafetyMode.NONE
class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback: (Int) -> Unit) :
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
- private val background: ColorDrawable = ColorDrawable(Color.parseColor("#f44336"))
- private val iconDelete =
- ContextCompat.getDrawable(context, R.drawable.ic_baseline_delete_white_24)!!
+ private val background: ColorDrawable by lazy(NONE) {
+ ColorDrawable(getColor(context, R.color.swipe_to_delete_background_color))
+ }
+ private val iconDelete by lazy(NONE) {
+ getDrawable(context, R.drawable.ic_baseline_delete_white_24)!!
+ }
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder
+ target: RecyclerView.ViewHolder,
) = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
@@ -34,7 +38,7 @@ class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback:
dX: Float,
dY: Float,
actionState: Int,
- isCurrentlyActive: Boolean
+ isCurrentlyActive: Boolean,
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
val itemView = viewHolder.itemView
@@ -56,7 +60,10 @@ class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback:
itemView.bottom
)
}
- else -> background.setBounds(0, 0, 0, 0)
+ else -> {
+ background.setBounds(0, 0, 0, 0)
+ iconDelete.setBounds(0, 0, 0, 0)
+ }
}
background.draw(c)
iconDelete.draw(c)
diff --git a/core-ui/src/main/res/font/noto_sans.xml b/core-ui/src/main/res/font/noto_sans.xml
index 6cbfda08..50d1747e 100644
--- a/core-ui/src/main/res/font/noto_sans.xml
+++ b/core-ui/src/main/res/font/noto_sans.xml
@@ -1,7 +1,6 @@
-
+ app:fontProviderAuthority="com.google.android.gms.fonts"
+ app:fontProviderCerts="@array/com_google_android_gms_fonts_certs"
+ app:fontProviderPackage="com.google.android.gms"
+ app:fontProviderQuery="Noto Sans">
diff --git a/core-ui/src/main/res/values-night/themes.xml b/core-ui/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..1f14d006
--- /dev/null
+++ b/core-ui/src/main/res/values-night/themes.xml
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/core-ui/src/main/res/values/colors.xml b/core-ui/src/main/res/values/colors.xml
index 030098fe..fd84d864 100644
--- a/core-ui/src/main/res/values/colors.xml
+++ b/core-ui/src/main/res/values/colors.xml
@@ -1,6 +1,58 @@
-
- #6200EE
- #3700B3
- #03DAC5
+ #6750A4
+ #FFFFFF
+ #EADDFF
+ #21005D
+ #625B71
+ #FFFFFF
+ #E8DEF8
+ #1D192B
+ #7D5260
+ #FFFFFF
+ #FFD8E4
+ #31111D
+ #B3261E
+ #F9DEDC
+ #FFFFFF
+ #410E0B
+ #FFFBFE
+ #1C1B1F
+ #FFFBFE
+ #1C1B1F
+ #E7E0EC
+ #49454F
+ #9A989C
+ #F4EFF4
+ #313033
+ #D0BCFF
+
+ #D0BCFF
+ #381E72
+ #4F378B
+ #EADDFF
+ #CCC2DC
+ #332D41
+ #4A4458
+ #E8DEF8
+ #EFB8C8
+ #492532
+ #633B48
+ #FFD8E4
+ #F2B8B5
+ #8C1D18
+ #601410
+ #F9DEDC
+ #1C1B1F
+ #E6E1E5
+ #1C1B1F
+ #E6E1E5
+ #49454F
+ #CAC4D0
+ #6D6775
+ #1C1B1F
+ #E6E1E5
+ #6750A4
+ #6750A4
+ #B3261E
+ #f44336
diff --git a/core-ui/src/main/res/values/styles.xml b/core-ui/src/main/res/values/styles.xml
index 1c56072b..2c5844dd 100644
--- a/core-ui/src/main/res/values/styles.xml
+++ b/core-ui/src/main/res/values/styles.xml
@@ -1,11 +1 @@
-
-
-
-
-
-
+
diff --git a/core-ui/src/main/res/values/themes.xml b/core-ui/src/main/res/values/themes.xml
new file mode 100644
index 00000000..e010bd31
--- /dev/null
+++ b/core-ui/src/main/res/values/themes.xml
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index be8b8cde..ae2afcfd 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -3,8 +3,8 @@ plugins {
}
java {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
dependencies {
diff --git a/core/src/main/java/com/hoc/flowmvi/core/SuspendRetry.kt b/core/src/main/java/com/hoc/flowmvi/core/SuspendRetry.kt
deleted file mode 100644
index ab55a013..00000000
--- a/core/src/main/java/com/hoc/flowmvi/core/SuspendRetry.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.hoc.flowmvi.core
-
-import kotlinx.coroutines.delay
-import kotlin.time.Duration
-import kotlin.time.ExperimentalTime
-
-@ExperimentalTime
-suspend inline fun retrySuspend(
- times: Int,
- initialDelay: Duration,
- factor: Double,
- maxDelay: Duration = Duration.INFINITE,
- shouldRetry: (Throwable) -> Boolean = { true },
- block: (times: Int) -> T,
-): T {
- var currentDelay = initialDelay
- repeat(times - 1) {
- try {
- return block(it)
- } catch (e: Throwable) {
- if (!shouldRetry(e)) {
- throw e
- }
- // you can log an error here and/or make a more finer-grained
- // analysis of the cause to see if retry is needed
- }
- delay(currentDelay)
- currentDelay = (currentDelay * factor).coerceAtMost(maxDelay)
- }
- return block(times - 1) // last attempt
-}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 6aa10c16..a70eba5f 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -27,10 +27,10 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
testOptions {
unitTests.isIncludeAndroidResources = true
@@ -43,6 +43,7 @@ dependencies {
implementation(domain)
implementation(deps.coroutines.core)
+ implementation(deps.flowExt)
implementation(deps.squareup.retrofit)
implementation(deps.squareup.moshiKotlin)
diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
index fe5edcd8..72621c55 100644
--- a/data/src/main/AndroidManifest.xml
+++ b/data/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
+
diff --git a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
index 710fac77..45ade5ef 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
@@ -10,6 +10,7 @@ import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
@@ -22,6 +23,8 @@ import kotlin.time.ExperimentalTime
val BASE_URL_QUALIFIER = named("BASE_URL")
+@JvmField
+@FlowPreview
@ExperimentalStdlibApi
@ExperimentalTime
@ExperimentalCoroutinesApi
diff --git a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
index f38aad37..ac7022ae 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
@@ -8,7 +8,6 @@ import arrow.core.right
import arrow.core.valueOr
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
-import com.hoc.flowmvi.core.retrySuspend
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.data.remote.UserResponse
@@ -16,22 +15,26 @@ import com.hoc.flowmvi.domain.model.User
import com.hoc.flowmvi.domain.model.UserError
import com.hoc.flowmvi.domain.model.UserValidationError
import com.hoc.flowmvi.domain.repository.UserRepository
+import com.hoc081098.flowext.retryWithExponentialBackoff
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.emitAll
-import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
-import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
import arrow.core.Either.Companion.catch as catchEither
+@FlowPreview
@ExperimentalTime
@ExperimentalCoroutinesApi
internal class UserRepositoryImpl(
@@ -65,44 +68,38 @@ internal class UserRepositoryImpl(
@Suppress("NOTHING_TO_INLINE")
private inline fun logError(t: Throwable, message: String) = Timber.tag(TAG).e(t, message)
- private suspend fun getUsersFromRemote(): List {
- return withContext(dispatchers.io) {
- retrySuspend(
- times = 3,
- initialDelay = Duration.milliseconds(500),
- factor = 2.0,
- shouldRetry = { it is IOException }
- ) { times ->
- Timber.d("[USER_REPO] Retry times=$times")
- userApiService
- .getUsers()
- .map(responseToDomainThrows)
- }
- }
- }
-
- override fun getUsers() = flow {
- val initial = getUsersFromRemote()
-
- changesFlow
- .onEach { Timber.d("[USER_REPO] Change=$it") }
- .scan(initial) { acc, change ->
- when (change) {
- is Change.Removed -> acc.filter { it.id != change.removed.id }
- is Change.Refreshed -> change.user
- is Change.Added -> acc + change.user
+ private fun getUsersFromRemote(): Flow> = suspend {
+ Timber.d("[USER_REPO] getUsersFromRemote ...")
+ userApiService
+ .getUsers()
+ .map(responseToDomainThrows)
+ }.asFlow()
+ .retryWithExponentialBackoff(
+ maxAttempt = 2,
+ initialDelay = 500.milliseconds,
+ factor = 2.0,
+ ) { it is IOException }
+
+ override fun getUsers() = getUsersFromRemote()
+ .flatMapConcat { initial ->
+ changesFlow
+ .onEach { Timber.d("[USER_REPO] Change=$it") }
+ .scan(initial) { acc, change ->
+ when (change) {
+ is Change.Removed -> acc.filter { it.id != change.removed.id }
+ is Change.Refreshed -> change.user
+ is Change.Added -> acc + change.user
+ }
}
- }
- .onEach { Timber.d("[USER_REPO] Emit users.size=${it.size} ") }
- .let { emitAll(it) }
- }
+ }
+ .onEach { Timber.d("[USER_REPO] Emit users.size=${it.size} ") }
.map { it.right().leftWiden>() }
.catch {
logError(it, "getUsers")
emit(errorMapper(it).left())
}
- override suspend fun refresh() = catchEither { getUsersFromRemote() }
+ override suspend fun refresh() = catchEither { getUsersFromRemote().first() }
.tap { sendChange(Change.Refreshed(it)) }
.map { }
.tapLeft { logError(it, "refresh") }
@@ -131,8 +128,6 @@ internal class UserRepositoryImpl(
.mapLeft(errorMapper)
.bind()
- delay(400) // TODO
-
val added = responseToDomain(response)
.mapLeft { UserError.ValidationFailed(it.toSet()) }
.tapInvalid { logError(it, "add user=$user") }
diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt
index cd2a2b68..9e0c8436 100644
--- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt
+++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
+import org.koin.core.logger.Level
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
@@ -30,7 +31,8 @@ import kotlin.time.ExperimentalTime
class UserRepositoryImplRealAPITest : KoinTest {
@get:Rule
val koinRuleTest = KoinTestRule.create {
- printLogger()
+ // TODO(koin): https://github.com/InsertKoinIO/koin/issues/1188
+ printLogger(Level.ERROR)
modules(
dataModule,
module {
diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
index 83376dfd..f465a5dc 100644
--- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
+++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
@@ -26,10 +26,11 @@ import io.mockk.verify
import io.mockk.verifySequence
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.runTest
import org.junit.Rule
import java.io.IOException
import kotlin.test.AfterTest
@@ -98,12 +99,12 @@ private val USERS = listOf(
private val VALID_NEL_USERS = USERS.map(User::validNel)
+@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalTime
class UserRepositoryImplTest {
@get:Rule
val coroutineRule = TestCoroutineDispatcherRule()
- private val testDispatcher get() = coroutineRule.testCoroutineDispatcher
private lateinit var repo: UserRepositoryImpl
private lateinit var userApiService: UserApiService
@@ -120,7 +121,7 @@ class UserRepositoryImplTest {
repo = UserRepositoryImpl(
userApiService = userApiService,
- dispatchers = TestDispatchers(coroutineRule.testCoroutineDispatcher),
+ dispatchers = TestDispatchers(coroutineRule.testDispatcher),
responseToDomain = responseToDomain,
domainToBody = domainToBody,
errorMapper = errorMapper
@@ -139,7 +140,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_refresh_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ fun test_refresh_withApiCallSuccess_returnsRight() = runTest {
coEvery { userApiService.getUsers() } returns USER_RESPONSES
every { responseToDomain(any()) } returnsMany VALID_NEL_USERS
@@ -157,7 +158,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_refresh_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ fun test_refresh_withApiCallError_returnsLeft() = runTest {
val ioException = IOException()
coEvery { userApiService.getUsers() } throws ioException
every { errorMapper(ofType()) } returns UserError.NetworkError
@@ -166,12 +167,12 @@ class UserRepositoryImplTest {
assertTrue(result.isLeft())
assertEquals(UserError.NetworkError, result.leftOrThrow)
- coVerify(exactly = 3) { userApiService.getUsers() } // retry 3 times
+ coVerify(exactly = 3) { userApiService.getUsers() } // retry 2 times
verify(exactly = 1) { errorMapper(ofType()) }
}
@Test
- fun test_remove_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ fun test_remove_withApiCallSuccess_returnsRight() = runTest {
val user = USERS[0]
val userResponse = USER_RESPONSES[0]
@@ -188,7 +189,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_remove_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ fun test_remove_withApiCallError_returnsLeft() = runTest {
val user = USERS[0]
coEvery { userApiService.remove(user.id) } throws IOException()
every { errorMapper(ofType()) } returns UserError.NetworkError
@@ -202,7 +203,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_add_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ fun test_add_withApiCallSuccess_returnsRight() = runTest {
val user = USERS[0]
val userResponse = USER_RESPONSES[0]
@@ -221,7 +222,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_add_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ fun test_add_withApiCallError_returnsLeft() = runTest {
val user = USERS[0]
coEvery { userApiService.add(USER_BODY) } throws IOException()
every { domainToBody(user) } returns USER_BODY
@@ -238,7 +239,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_search_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ fun test_search_withApiCallSuccess_returnsRight() = runTest {
val q = "hoc081098"
coEvery { userApiService.search(q) } returns USER_RESPONSES
every { responseToDomain(any()) } returnsMany VALID_NEL_USERS
@@ -258,7 +259,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_search_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ fun test_search_withApiCallError_returnsLeft() = runTest {
val q = "hoc081098"
coEvery { userApiService.search(q) } throws IOException()
every { errorMapper(ofType()) } returns UserError.NetworkError
@@ -273,7 +274,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_getUsers_withApiCallSuccess_emitsInitial() = testDispatcher.runBlockingTest {
+ fun test_getUsers_withApiCallSuccess_emitsInitial() = runTest {
coEvery { userApiService.getUsers() } returns USER_RESPONSES
every { responseToDomain(any()) } returnsMany VALID_NEL_USERS
@@ -299,7 +300,7 @@ class UserRepositoryImplTest {
}
@Test
- fun test_getUsers_withApiCallError_rethrows() = testDispatcher.runBlockingTest {
+ fun test_getUsers_withApiCallError_rethrows() = runTest {
coEvery { userApiService.getUsers() } throws IOException()
every { errorMapper(ofType()) } returns UserError.NetworkError
@@ -316,13 +317,13 @@ class UserRepositoryImplTest {
assertNull(result.orNull())
assertEquals(UserError.NetworkError, result.leftOrThrow)
- coVerify(exactly = 3) { userApiService.getUsers() } // retry 3 times.
+ coVerify(exactly = 3) { userApiService.getUsers() } // retry 2 times.
verify(exactly = 1) { errorMapper(ofType()) }
}
@Test
fun test_getUsers_withApiCallSuccess_emitsInitialAndUpdatedUsers() =
- testDispatcher.runBlockingTest {
+ runTest {
val user = USERS.last()
val userResponse = USER_RESPONSES.last()
coEvery { userApiService.getUsers() } returns USER_RESPONSES.dropLast(1)
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
index 96639e96..9fed188e 100644
--- a/domain/build.gradle.kts
+++ b/domain/build.gradle.kts
@@ -3,8 +3,8 @@ plugins {
}
java {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
dependencies {
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt b/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
index 9f9691a6..ef4f868f 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
@@ -7,6 +7,7 @@ import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase
import org.koin.dsl.module
+@JvmField
val domainModule = module {
factory { GetUsersUseCase(userRepository = get()) }
diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
index 9082db08..af430476 100644
--- a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
+++ b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
@@ -22,7 +22,7 @@ import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.runTest
import org.junit.Rule
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -57,7 +57,6 @@ private val USERS = listOf(
class UseCaseTest {
@get:Rule
val coroutineRule = TestCoroutineDispatcherRule()
- private val testDispatcher get() = coroutineRule.testCoroutineDispatcher
private lateinit var userRepository: UserRepository
private lateinit var getUsersUseCase: GetUsersUseCase
@@ -86,7 +85,7 @@ class UseCaseTest {
}
@Test
- fun test_getUsersUseCase_whenSuccess_emitsUsers() = testDispatcher.runBlockingTest {
+ fun test_getUsersUseCase_whenSuccess_emitsUsers() = runTest {
val usersRight = USERS.right()
every { userRepository.getUsers() } returns flowOf(usersRight)
@@ -97,7 +96,7 @@ class UseCaseTest {
}
@Test
- fun test_getUsersUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
+ fun test_getUsersUseCase_whenError_throwsError() = runTest {
every { userRepository.getUsers() } returns flowOf(errorLeft)
val result = getUsersUseCase()
@@ -107,7 +106,7 @@ class UseCaseTest {
}
@Test
- fun test_refreshUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
+ fun test_refreshUseCase_whenSuccess_returnsUnit() = runTest {
coEvery { userRepository.refresh() } returns Unit.right()
val result = refreshUseCase()
@@ -117,7 +116,7 @@ class UseCaseTest {
}
@Test
- fun test_refreshUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
+ fun test_refreshUseCase_whenError_throwsError() = runTest {
coEvery { userRepository.refresh() } returns errorLeft
val result = refreshUseCase()
@@ -127,7 +126,7 @@ class UseCaseTest {
}
@Test
- fun test_removeUserUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
+ fun test_removeUserUseCase_whenSuccess_returnsUnit() = runTest {
coEvery { userRepository.remove(any()) } returns Unit.right()
val result = removeUserUseCase(USERS[0])
@@ -137,7 +136,7 @@ class UseCaseTest {
}
@Test
- fun test_removeUserUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
+ fun test_removeUserUseCase_whenError_throwsError() = runTest {
coEvery { userRepository.remove(any()) } returns errorLeft
val result = removeUserUseCase(USERS[0])
@@ -147,7 +146,7 @@ class UseCaseTest {
}
@Test
- fun test_addUserUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
+ fun test_addUserUseCase_whenSuccess_returnsUnit() = runTest {
coEvery { userRepository.add(any()) } returns Unit.right()
val result = addUserUseCase(USERS[0])
@@ -157,7 +156,7 @@ class UseCaseTest {
}
@Test
- fun test_addUserUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
+ fun test_addUserUseCase_whenError_throwsError() = runTest {
coEvery { userRepository.add(any()) } returns errorLeft
val result = addUserUseCase(USERS[0])
@@ -167,7 +166,7 @@ class UseCaseTest {
}
@Test
- fun test_searchUsersUseCase_whenSuccess_returnsUsers() = testDispatcher.runBlockingTest {
+ fun test_searchUsersUseCase_whenSuccess_returnsUsers() = runTest {
coEvery { userRepository.search(any()) } returns USERS.right()
val query = "hoc081098"
@@ -178,7 +177,7 @@ class UseCaseTest {
}
@Test
- fun test_searchUsersUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
+ fun test_searchUsersUseCase_whenError_throwsError() = runTest {
coEvery { userRepository.search(any()) } returns errorLeft
val query = "hoc081098"
diff --git a/feature-add/build.gradle.kts b/feature-add/build.gradle.kts
index 02b658ae..d422dbde 100644
--- a/feature-add/build.gradle.kts
+++ b/feature-add/build.gradle.kts
@@ -27,10 +27,10 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
buildFeatures { viewBinding = true }
testOptions {
diff --git a/feature-add/src/main/AndroidManifest.xml b/feature-add/src/main/AndroidManifest.xml
index de206c62..365cc847 100644
--- a/feature-add/src/main/AndroidManifest.xml
+++ b/feature-add/src/main/AndroidManifest.xml
@@ -1,13 +1,13 @@
+ package="com.hoc.flowmvi.ui.add">
-
+
-
+
-
+
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 812e5b66..5d13b899 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
@@ -67,6 +67,7 @@ class AddActivity :
else null
}
+ TransitionManager.endTransitions(addBinding.root)
TransitionManager.beginDelayedTransition(
addBinding.root,
AutoTransition()
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt
index ca1f178d..184ffe8b 100644
--- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt
@@ -5,6 +5,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
+@JvmField
@ExperimentalCoroutinesApi
val addModule = module {
viewModel { params ->
diff --git a/feature-add/src/main/res/layout/activity_add.xml b/feature-add/src/main/res/layout/activity_add.xml
index 94ee3ac6..69174df3 100644
--- a/feature-add/src/main/res/layout/activity_add.xml
+++ b/feature-add/src/main/res/layout/activity_add.xml
@@ -2,8 +2,7 @@
+ android:layout_height="match_parent">
+ android:layout_height="wrap_content">
+ android:singleLine="true" />
+ android:singleLine="true" />
+ android:singleLine="true" />
-
+ package="com.hoc.flowmvi.ui.main">
-
+
-
-
-
+
+
+
-
-
-
+
+
+
-
+
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 e4560a04..3231e46e 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
@@ -3,12 +3,13 @@ package com.hoc.flowmvi.ui.main
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isVisible
-import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.divider.MaterialDividerItemDecoration
import com.hoc.flowmvi.core_ui.SwipeLeftToDeleteCallback
import com.hoc.flowmvi.core_ui.clicks
+import com.hoc.flowmvi.core_ui.dpToPx
import com.hoc.flowmvi.core_ui.navigator.Navigator
import com.hoc.flowmvi.core_ui.refreshes
import com.hoc.flowmvi.core_ui.toast
@@ -62,7 +63,14 @@ class MainActivity :
setHasFixedSize(true)
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
adapter = userAdapter
- addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
+ addItemDecoration(
+ MaterialDividerItemDecoration(context, RecyclerView.VERTICAL).apply {
+ dividerInsetStart = dpToPx(8f)
+ dividerInsetEnd = dpToPx(8f)
+ isLastItemDecorated = false
+ dividerThickness = dpToPx(0.8f)
+ }
+ )
ItemTouchHelper(
SwipeLeftToDeleteCallback(context) cb@{ position ->
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt
index 95c1ec18..6d91c7cb 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt
@@ -5,6 +5,7 @@ import kotlinx.coroutines.FlowPreview
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
+@JvmField
@ExperimentalCoroutinesApi
@FlowPreview
val mainModule = module {
diff --git a/feature-main/src/main/res/drawable/ic_add_white_24dp.xml b/feature-main/src/main/res/drawable/ic_add_white_24dp.xml
index e3979cd7..5257a22e 100644
--- a/feature-main/src/main/res/drawable/ic_add_white_24dp.xml
+++ b/feature-main/src/main/res/drawable/ic_add_white_24dp.xml
@@ -1,5 +1,10 @@
-
-
+
+
diff --git a/feature-main/src/main/res/drawable/ic_baseline_search_24.xml b/feature-main/src/main/res/drawable/ic_baseline_search_24.xml
index 07b76d62..cbf0cc71 100644
--- a/feature-main/src/main/res/drawable/ic_baseline_search_24.xml
+++ b/feature-main/src/main/res/drawable/ic_baseline_search_24.xml
@@ -1,10 +1,10 @@
-
+ android:viewportHeight="24">
+
diff --git a/feature-main/src/main/res/layout/activity_main.xml b/feature-main/src/main/res/layout/activity_main.xml
index 9772a99a..83ae9793 100644
--- a/feature-main/src/main/res/layout/activity_main.xml
+++ b/feature-main/src/main/res/layout/activity_main.xml
@@ -4,10 +4,8 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="@android:color/white"
tools:context=".MainActivity">
-
-
-
-
+ android:layout_height="wrap_content">
-
-
-
-
+
diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
index 6296d5aa..e960efb2 100644
--- a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
+++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
@@ -8,7 +8,9 @@ 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_testing.BaseMviViewModelTest
+import com.hoc.flowmvi.mvi_testing.delayEach
import com.hoc.flowmvi.mvi_testing.mapRight
+import com.hoc.flowmvi.mvi_testing.returnsWithDelay
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifySequence
@@ -25,7 +27,6 @@ import kotlinx.coroutines.flow.update
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
-import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@ExperimentalTime
@@ -67,29 +68,31 @@ class MainVMTest : BaseMviViewModelTest<
}
@Test
- fun test_withInitialIntentWhenSuccess_returnsUserItems() = test(
- vmProducer = {
- every { getUserUseCase() } returns flowOf(USERS.right())
- vm
- },
- intents = flowOf(ViewIntent.Initial),
- expectedStates = listOf(
- ViewState.initial(),
- ViewState(
- userItems = USER_ITEMS,
- isLoading = false,
- error = null,
- isRefreshing = false
- )
- ).mapRight(),
- expectedEvents = emptyList(),
- ) { verify(exactly = 1) { getUserUseCase() } }
+ fun test_withInitialIntentWhenSuccess_returnsUserItems() {
+ runVMTest(
+ vmProducer = {
+ every { getUserUseCase() } returns flowOf(USERS.right())
+ vm
+ },
+ intents = flowOf(ViewIntent.Initial),
+ expectedStates = listOf(
+ ViewState.initial(),
+ ViewState(
+ userItems = USER_ITEMS,
+ isLoading = false,
+ error = null,
+ isRefreshing = false
+ )
+ ).mapRight(),
+ expectedEvents = emptyList(),
+ ) { verify(exactly = 1) { getUserUseCase() } }
+ }
@Test
fun test_withInitialIntentWhenError_returnsErrorState() {
val userError = UserError.NetworkError
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returns flowOf(userError.left())
vm
@@ -113,53 +116,54 @@ class MainVMTest : BaseMviViewModelTest<
}
@Test
- fun test_withRefreshIntentWhenSuccess_isNotRefreshing() = test(
- vmProducer = {
- every { getUserUseCase() } returns flowOf(USERS.right())
- coEvery { refreshGetUsersUseCase() } returns Unit.right()
- vm
- },
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
- intents = flowOf(ViewIntent.Refresh),
- expectedStates = listOf(
- ViewState(
- userItems = USER_ITEMS,
- isLoading = false,
- error = null,
- isRefreshing = false
- ),
- ViewState(
- userItems = USER_ITEMS,
- isLoading = false,
- error = null,
- isRefreshing = true,
- ),
- ViewState(
- userItems = USER_ITEMS,
- isLoading = false,
- error = null,
- isRefreshing = false
- ),
- ).mapRight(),
- expectedEvents = listOf(
- SingleEvent.Refresh.Success
- ).mapRight(),
- ) {
- coVerify(exactly = 1) { getUserUseCase() }
- coVerify(exactly = 1) { refreshGetUsersUseCase() }
+ fun test_withRefreshIntentWhenSuccess_isNotRefreshing() {
+ runVMTest(
+ vmProducer = {
+ every { getUserUseCase() } returns flowOf(USERS.right()).delayEach()
+ coEvery { refreshGetUsersUseCase() } returnsWithDelay Unit.right()
+ vm
+ },
+ intents = flowOf(ViewIntent.Refresh),
+ expectedStates = listOf(
+ ViewState(
+ userItems = USER_ITEMS,
+ isLoading = false,
+ error = null,
+ isRefreshing = false
+ ),
+ ViewState(
+ userItems = USER_ITEMS,
+ isLoading = false,
+ error = null,
+ isRefreshing = true,
+ ),
+ ViewState(
+ userItems = USER_ITEMS,
+ isLoading = false,
+ error = null,
+ isRefreshing = false
+ ),
+ ).mapRight(),
+ expectedEvents = listOf(
+ SingleEvent.Refresh.Success
+ ).mapRight(),
+ preProcessingIntents = flowOf(ViewIntent.Initial),
+ ) {
+ coVerify(exactly = 1) { getUserUseCase() }
+ coVerify(exactly = 1) { refreshGetUsersUseCase() }
+ }
}
@Test
fun test_withRefreshIntentWhenFailure_isNotRefreshing() {
val userError = UserError.NetworkError
- test(
+ runVMTest(
vmProducer = {
- coEvery { getUserUseCase() } returns flowOf(USERS.right())
- coEvery { refreshGetUsersUseCase() } returns userError.left()
+ every { getUserUseCase() } returns flowOf(USERS.right()).delayEach()
+ coEvery { refreshGetUsersUseCase() } returnsWithDelay userError.left()
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(ViewIntent.Refresh),
expectedStates = listOf(
ViewState(
@@ -184,6 +188,7 @@ class MainVMTest : BaseMviViewModelTest<
expectedEvents = listOf(
SingleEvent.Refresh.Failure(userError)
).mapRight(),
+ preProcessingIntents = flowOf(ViewIntent.Initial),
) {
coVerify(exactly = 1) { getUserUseCase() }
coVerify(exactly = 1) { refreshGetUsersUseCase() }
@@ -192,7 +197,7 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withRefreshIntent_ignoredWhenIsLoading() {
- test(
+ runVMTest(
vmProducer = {
coEvery { refreshGetUsersUseCase() } returns Unit.right()
vm
@@ -200,7 +205,6 @@ class MainVMTest : BaseMviViewModelTest<
intents = flowOf(ViewIntent.Refresh),
expectedStates = listOf(ViewState.initial()).mapRight(),
expectedEvents = emptyList(),
- delayAfterDispatchingIntents = Duration.milliseconds(100),
) { coVerify(exactly = 0) { refreshGetUsersUseCase() } }
}
@@ -208,13 +212,12 @@ class MainVMTest : BaseMviViewModelTest<
fun test_withRefreshIntent_ignoredWhenHavingError() {
val userError = UserError.NetworkError
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returns flowOf(userError.left())
coEvery { refreshGetUsersUseCase() } returns Unit.right()
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(ViewIntent.Refresh),
expectedStates = listOf(
ViewState(
@@ -225,7 +228,7 @@ class MainVMTest : BaseMviViewModelTest<
)
).mapRight(),
expectedEvents = emptyList(),
- delayAfterDispatchingIntents = Duration.milliseconds(100),
+ preProcessingIntents = flowOf(ViewIntent.Initial),
) {
coVerify(exactly = 1) { getUserUseCase() }
coVerify(exactly = 0) { refreshGetUsersUseCase() }
@@ -234,7 +237,7 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withRetryIntent_ignoredWhenHavingNoError() {
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returns emptyFlow()
vm
@@ -242,7 +245,6 @@ class MainVMTest : BaseMviViewModelTest<
intents = flowOf(ViewIntent.Retry),
expectedStates = listOf(ViewState.initial()).mapRight(),
expectedEvents = emptyList(),
- delayAfterDispatchingIntents = Duration.milliseconds(100),
) { coVerify(exactly = 0) { getUserUseCase() } }
}
@@ -250,15 +252,14 @@ class MainVMTest : BaseMviViewModelTest<
fun test_withRetryIntentWhenSuccess_returnsUserItems() {
val userError = UserError.NetworkError
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returnsMany listOf(
- flowOf(userError.left()),
- flowOf(USERS.right()),
+ flowOf(userError.left()).delayEach(),
+ flowOf(USERS.right()).delayEach(),
)
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(ViewIntent.Retry),
expectedStates = listOf(
ViewState(
@@ -281,6 +282,7 @@ class MainVMTest : BaseMviViewModelTest<
)
).mapRight(),
expectedEvents = emptyList(),
+ preProcessingIntents = flowOf(ViewIntent.Initial),
) { verify(exactly = 2) { getUserUseCase() } }
}
@@ -289,15 +291,14 @@ class MainVMTest : BaseMviViewModelTest<
val userError1 = UserError.NetworkError
val userError2 = UserError.Unexpected
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returnsMany listOf(
- flowOf(userError1.left()),
- flowOf(userError2.left()),
+ flowOf(userError1.left()).delayEach(),
+ flowOf(userError2.left()).delayEach(),
)
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(ViewIntent.Retry),
expectedStates = listOf(
ViewState(
@@ -322,6 +323,7 @@ class MainVMTest : BaseMviViewModelTest<
expectedEvents = listOf(
SingleEvent.GetUsersError(userError2),
).mapRight(),
+ preProcessingIntents = flowOf(ViewIntent.Initial),
) { verify(exactly = 2) { getUserUseCase() } }
}
@@ -333,7 +335,7 @@ class MainVMTest : BaseMviViewModelTest<
val item2 = USER_ITEMS[1]
val usersFlow = MutableStateFlow(USERS.right())
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returns usersFlow
coEvery { removeUser(any()) } coAnswers {
@@ -346,7 +348,6 @@ class MainVMTest : BaseMviViewModelTest<
}
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(
ViewIntent.RemoveUser(item1),
ViewIntent.RemoveUser(item2),
@@ -377,7 +378,8 @@ class MainVMTest : BaseMviViewModelTest<
expectedEvents = listOf(
SingleEvent.RemoveUser.Success(item1),
SingleEvent.RemoveUser.Success(item2),
- ).mapRight()
+ ).mapRight(),
+ preProcessingIntents = flowOf(ViewIntent.Initial)
) {
coVerify(exactly = 1) { getUserUseCase() }
coVerifySequence {
@@ -393,13 +395,12 @@ class MainVMTest : BaseMviViewModelTest<
val item = USER_ITEMS[0]
val userError = UserError.NetworkError
- test(
+ runVMTest(
vmProducer = {
every { getUserUseCase() } returns flowOf(USERS.right())
coEvery { removeUser(any()) } returns userError.left()
vm
},
- intentsBeforeCollecting = flowOf(ViewIntent.Initial),
intents = flowOf(ViewIntent.RemoveUser(item)),
expectedStates = listOf(
ViewState(
@@ -416,7 +417,8 @@ class MainVMTest : BaseMviViewModelTest<
assertEquals(userError, removed.error)
assertEquals(removed.indexProducer(), 0)
}.left(),
- )
+ ),
+ preProcessingIntents = flowOf(ViewIntent.Initial)
) {
coVerify(exactly = 1) { getUserUseCase() }
coVerify(exactly = 1) { removeUser(user) }
diff --git a/feature-search/build.gradle.kts b/feature-search/build.gradle.kts
index c8b07fe6..006f55dd 100644
--- a/feature-search/build.gradle.kts
+++ b/feature-search/build.gradle.kts
@@ -27,10 +27,10 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
buildFeatures { viewBinding = true }
testOptions {
diff --git a/feature-search/src/main/AndroidManifest.xml b/feature-search/src/main/AndroidManifest.xml
index c7eee54e..2e174979 100644
--- a/feature-search/src/main/AndroidManifest.xml
+++ b/feature-search/src/main/AndroidManifest.xml
@@ -1,11 +1,11 @@
+ package="com.hoc.flowmvi.ui.search">
-
-
-
+
+
+
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 a5559176..7579065e 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
@@ -10,6 +10,8 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
+import androidx.transition.AutoTransition
+import androidx.transition.TransitionManager
import com.hoc.flowmvi.core_ui.SearchViewQueryTextEvent
import com.hoc.flowmvi.core_ui.clicks
import com.hoc.flowmvi.core_ui.navigator.IntentProviders
@@ -58,6 +60,15 @@ class SearchActivity :
textQuery.text = "Search results for '${viewState.submittedQuery}'"
}
+ TransitionManager.endTransitions(root)
+ TransitionManager.beginDelayedTransition(
+ root,
+ AutoTransition()
+ .addTarget(errorGroup)
+ .addTarget(progressBar)
+ .setDuration(200)
+ )
+
errorGroup.isVisible = viewState.error !== null
if (errorGroup.isVisible) {
errorMessageTextView.text = viewState.error?.let {
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 48c01a30..aa6e0af3 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
@@ -21,7 +21,7 @@ data class UserItem private constructor(
id = domain.id,
email = domain.email.value,
avatar = domain.avatar,
- fullName = "${domain.firstName} ${domain.lastName}",
+ fullName = "${domain.firstName.value} ${domain.lastName.value}",
)
}
}
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 64895147..e52baf28 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
@@ -7,6 +7,7 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import kotlin.time.ExperimentalTime
+@JvmField
@ExperimentalCoroutinesApi
@FlowPreview
@ExperimentalTime
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 a7fda6b4..86af3af4 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
@@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
-import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
@FlowPreview
@@ -109,6 +109,6 @@ class SearchVM(
internal companion object {
private const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query"
- internal val SEARCH_DEBOUNCE_DURATION = Duration.milliseconds(400)
+ internal val SEARCH_DEBOUNCE_DURATION = 400.milliseconds
}
}
diff --git a/feature-search/src/main/res/drawable/ic_baseline_search_24.xml b/feature-search/src/main/res/drawable/ic_baseline_search_24.xml
index 07b76d62..cbf0cc71 100644
--- a/feature-search/src/main/res/drawable/ic_baseline_search_24.xml
+++ b/feature-search/src/main/res/drawable/ic_baseline_search_24.xml
@@ -1,10 +1,10 @@
-
+ android:viewportHeight="24">
+
diff --git a/feature-search/src/main/res/layout/activity_search.xml b/feature-search/src/main/res/layout/activity_search.xml
index 56359fed..339ac6b5 100644
--- a/feature-search/src/main/res/layout/activity_search.xml
+++ b/feature-search/src/main/res/layout/activity_search.xml
@@ -1,95 +1,91 @@
-
-
+ android:layout_height="match_parent">
-
+
+
+
-
+
-
+
-
+
-
+
diff --git a/feature-search/src/main/res/layout/item_recycler_search_user.xml b/feature-search/src/main/res/layout/item_recycler_search_user.xml
index 4431f021..a09c8cc9 100644
--- a/feature-search/src/main/res/layout/item_recycler_search_user.xml
+++ b/feature-search/src/main/res/layout/item_recycler_search_user.xml
@@ -1,62 +1,61 @@
-
-
-
-
+ tools:layout_width="150dp">
-
+
+
+
+
+
diff --git a/feature-search/src/main/res/menu/menu_search.xml b/feature-search/src/main/res/menu/menu_search.xml
index 30027c2d..157ee94c 100644
--- a/feature-search/src/main/res/menu/menu_search.xml
+++ b/feature-search/src/main/res/menu/menu_search.xml
@@ -1,11 +1,11 @@
diff --git a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchContractTest.kt b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchContractTest.kt
new file mode 100644
index 00000000..907091c9
--- /dev/null
+++ b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchContractTest.kt
@@ -0,0 +1,49 @@
+package com.hoc.flowmvi.ui.search
+
+import com.hoc.flowmvi.domain.model.User
+import com.hoc.flowmvi.test_utils.valueOrThrow
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SearchContractTest {
+ @Test
+ fun test_userItem_equals() {
+ assertEquals(
+ UserItem.from(createUser()),
+ UserItem.from(createUser())
+ )
+ }
+
+ @Test
+ fun test_userItem_hashCode() {
+ assertEquals(
+ UserItem.from(createUser()).hashCode(),
+ UserItem.from(createUser()).hashCode()
+ )
+ }
+
+ @Test
+ fun test_userItem_properties() {
+ val item = UserItem.from(createUser())
+ assertEquals(ID, item.id)
+ assertEquals(EMAIL, item.email)
+ assertEquals(AVATAR, item.avatar)
+ assertEquals("$FIRST_NAME $LAST_NAME", item.fullName)
+ }
+
+ private companion object {
+ private const val ID = "0"
+ private const val EMAIL = "test@gmail.com"
+ private const val AVATAR = "avatar.png"
+ private const val FIRST_NAME = "first"
+ private const val LAST_NAME = "last"
+
+ private fun createUser(): User = User.create(
+ id = ID,
+ email = EMAIL,
+ avatar = AVATAR,
+ firstName = FIRST_NAME,
+ lastName = LAST_NAME
+ ).valueOrThrow
+ }
+}
diff --git a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt
index b93fcddf..782ecc4e 100644
--- a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt
+++ b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt
@@ -7,6 +7,8 @@ import com.hoc.flowmvi.domain.model.UserError
import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase
import com.hoc.flowmvi.mvi_testing.BaseMviViewModelTest
import com.hoc.flowmvi.mvi_testing.mapRight
+import com.hoc.flowmvi.mvi_testing.returnsManyWithDelay
+import com.hoc.flowmvi.mvi_testing.returnsWithDelay
import com.hoc.flowmvi.ui.search.SearchVM.Companion.SEARCH_DEBOUNCE_DURATION
import com.hoc081098.flowext.concatWith
import com.hoc081098.flowext.timer
@@ -24,7 +26,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlin.test.Test
-import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
@ExperimentalCoroutinesApi
@@ -56,9 +58,9 @@ class SearchVMTest : BaseMviViewModelTest(ViewIntent.Retry).concatWith(
flow {
delay(SEMI_TIMEOUT) // (2) very short ...
emit(ViewIntent.Search(query2))
@@ -561,11 +554,10 @@ class SearchVMTest : BaseMviViewModelTestNUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/mvi/mvi-base/build.gradle.kts b/mvi/mvi-base/build.gradle.kts
index 220af562..af2c4607 100644
--- a/mvi/mvi-base/build.gradle.kts
+++ b/mvi/mvi-base/build.gradle.kts
@@ -25,10 +25,10 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
}
dependencies {
diff --git a/mvi/mvi-base/src/main/AndroidManifest.xml b/mvi/mvi-base/src/main/AndroidManifest.xml
index e49ccc80..21977049 100644
--- a/mvi/mvi-base/src/main/AndroidManifest.xml
+++ b/mvi/mvi-base/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
+
diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt
index caef665b..785af398 100644
--- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt
+++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/AbstractMviViewModel.kt
@@ -1,6 +1,7 @@
package com.hoc.flowmvi.mvi_base
import android.os.Build
+import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
@@ -18,7 +19,7 @@ import timber.log.Timber
abstract class AbstractMviViewModel :
MviViewModel, ViewModel() {
protected val logTag by lazy(LazyThreadSafetyMode.PUBLICATION) {
- this::class.java.simpleName.let { tag ->
+ this::class.java.simpleName.let { tag: String ->
// Tag length limit was removed in API 26.
if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
tag
@@ -34,6 +35,12 @@ abstract class AbstractMviViewModel get() = eventChannel.receiveAsFlow()
final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent)
+ @CallSuper
+ override fun onCleared() {
+ super.onCleared()
+ eventChannel.close()
+ }
+
// Send event and access intent flow.
protected suspend fun sendEvent(event: E) = eventChannel.send(event)
diff --git a/mvi/mvi-testing/build.gradle.kts b/mvi/mvi-testing/build.gradle.kts
index f1697c6f..6613add3 100644
--- a/mvi/mvi-testing/build.gradle.kts
+++ b/mvi/mvi-testing/build.gradle.kts
@@ -41,10 +41,10 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
}
dependencies {
diff --git a/mvi/mvi-testing/src/main/AndroidManifest.xml b/mvi/mvi-testing/src/main/AndroidManifest.xml
index 3a1feaf0..9c3c1498 100644
--- a/mvi/mvi-testing/src/main/AndroidManifest.xml
+++ b/mvi/mvi-testing/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
+
diff --git a/mvi/mvi-testing/src/main/java/com/hoc/flowmvi/mvi_testing/BaseMviViewModelTest.kt b/mvi/mvi-testing/src/main/java/com/hoc/flowmvi/mvi_testing/BaseMviViewModelTest.kt
index e1a796fc..655f6ac6 100644
--- a/mvi/mvi-testing/src/main/java/com/hoc/flowmvi/mvi_testing/BaseMviViewModelTest.kt
+++ b/mvi/mvi-testing/src/main/java/com/hoc/flowmvi/mvi_testing/BaseMviViewModelTest.kt
@@ -8,25 +8,59 @@ import com.hoc.flowmvi.mvi_base.MviSingleEvent
import com.hoc.flowmvi.mvi_base.MviViewModel
import com.hoc.flowmvi.mvi_base.MviViewState
import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule
+import io.mockk.MockKAdditionalAnswerScope
+import io.mockk.MockKStubScope
import io.mockk.clearAllMocks
-import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
import org.junit.Rule
+import java.util.LinkedList
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.assertEquals
-import kotlin.time.Duration
import kotlin.time.ExperimentalTime
+fun Iterable.mapRight(): List Unit, T>> = map { it.right() }
+
+/**
+ * Workaround for [Kotlin/kotlinx.coroutines/issues/3120](https://github.com/Kotlin/kotlinx.coroutines/issues/3120).
+ * TODO(coroutines): https://github.com/Kotlin/kotlinx.coroutines/issues/3120
+ */
+fun Flow.delayEach() = onEach { delay(1) }
+
+/**
+ * Workaround for [Kotlin/kotlinx.coroutines/issues/3120](https://github.com/Kotlin/kotlinx.coroutines/issues/3120).
+ * TODO(coroutines): https://github.com/Kotlin/kotlinx.coroutines/issues/3120
+ */
+infix fun MockKStubScope.returnsWithDelay(returnValue: T): MockKAdditionalAnswerScope =
+ coAnswers {
+ delay(1)
+ returnValue
+ }
+
+/**
+ * Workaround for [Kotlin/kotlinx.coroutines/issues/3120](https://github.com/Kotlin/kotlinx.coroutines/issues/3120).
+ * TODO(coroutines): https://github.com/Kotlin/kotlinx.coroutines/issues/3120
+ */
+infix fun MockKStubScope.returnsManyWithDelay(values: List) {
+ var count = 0
+ coAnswers {
+ delay(1)
+ values[count++]
+ }
+}
+
@ExperimentalTime
@ExperimentalCoroutinesApi
abstract class BaseMviViewModelTest<
@@ -36,9 +70,11 @@ abstract class BaseMviViewModelTest<
VM : MviViewModel,
> {
@get:Rule
- val coroutineRule = TestCoroutineDispatcherRule()
-
- protected val testDispatcher get() = coroutineRule.testCoroutineDispatcher
+ val coroutineRule = TestCoroutineDispatcherRule(
+ testDispatcher = UnconfinedTestDispatcher(
+ name = "${this::class.java.simpleName}-UnconfinedTestDispatcher",
+ )
+ )
@CallSuper
@BeforeTest
@@ -51,90 +87,183 @@ abstract class BaseMviViewModelTest<
clearAllMocks()
}
- protected fun test(
+ protected fun runVMTest(
vmProducer: () -> VM,
intents: Flow,
expectedStates: List Unit, S>>,
expectedEvents: List Unit, E>>,
- delayAfterDispatchingIntents: Duration = Duration.ZERO,
- logging: Boolean = BuildConfig.ENABLE_LOG_TEST,
- intentsBeforeCollecting: Flow? = null,
+ loggingEnabled: Boolean = BuildConfig.ENABLE_LOG_TEST,
+ preProcessingIntents: Flow? = null,
otherAssertions: (suspend () -> Unit)? = null,
- ) = testDispatcher.runBlockingTest {
- fun logIfEnabled(s: () -> String) = if (logging) println(s()) else Unit
-
+ ) = runTest(coroutineRule.testDispatcher) {
val vm = vmProducer()
- intentsBeforeCollecting?.let { flow ->
- val job = vm.singleEvent.launchIn(this) // ignore events
-
- flow
- .onCompletion {
- job.cancel()
- logIfEnabled { "-".repeat(32) }
- }
- .collect {
- vm.processIntent(it)
- logIfEnabled { "[BEFORE] Dispatch $it -> $vm" }
- }
- }
+ preProcessingIntents?.let { prepare(vm, it, loggingEnabled) }
- logIfEnabled { "[START] $vm" }
+ // ------------------------------------- RUN -------------------------------------
- val states = mutableListOf()
- val events = mutableListOf()
+ val (states, events, stateJob, eventJob) = run(
+ vm = vm,
+ intents = intents,
+ expectedStates = expectedStates,
+ expectedEvents = expectedEvents,
+ loggingEnabled = loggingEnabled,
+ )
- val stateJob = launch(start = CoroutineStart.UNDISPATCHED) {
- vm.viewState.onEach { logIfEnabled { "[STATE] <- $it" } }.toList(states)
- }
- val eventJob = launch(start = CoroutineStart.UNDISPATCHED) { vm.singleEvent.toList(events) }
+ // ------------------------------------- DONE -------------------------------------
+
+ advanceUntilIdle()
+ runCurrent()
+
+ stateJob.cancelAndJoin()
+ eventJob.cancelAndJoin()
+
+ logIfEnabled(loggingEnabled) { DIVIDER }
+ logIfEnabled(loggingEnabled) { "[DONE] states=${states.joinToStringWithIndex()}" }
+ logIfEnabled(loggingEnabled) { "[DONE] events=${events.joinToStringWithIndex()}" }
+
+ // ------------------------------------- ASSERTIONS -------------------------------------
+
+ assertEquals(expectedStates.size, states.size, "States size")
+ assertEquals(expectedEvents.size, events.size, "Events size")
+ doAssertions(expectedStates, states, expectedEvents, events)
+
+ otherAssertions?.invoke()
+ }
+
+ private suspend fun TestScope.run(
+ vm: VM,
+ intents: Flow,
+ expectedStates: List Unit, S>>,
+ expectedEvents: List Unit, E>>,
+ loggingEnabled: Boolean,
+ ): RunResult {
+ logIfEnabled(loggingEnabled) { "[START] $vm" }
+
+ val states = LinkedList()
+ val events = LinkedList()
+ var stateIndex = 0
+ var eventIndex = 0
+
+ val stateJob = vm.viewState
+ .onEach { state ->
+ logIfEnabled(loggingEnabled) { "[STATE] <- $state" }
+
+ states += state
+ expectedStates[stateIndex].fold(
+ ifRight = {
+ assertEquals(
+ expected = it,
+ actual = state,
+ message = "[State index=$stateIndex]"
+ )
+ },
+ ifLeft = { it(state) }
+ )
+ ++stateIndex
+ }
+ .launchIn(this)
+
+ val eventJob = vm.singleEvent
+ .onEach { event ->
+ logIfEnabled(loggingEnabled) { "[EVENT] <- $event" }
+
+ events += event
+ expectedEvents[eventIndex].fold(
+ ifRight = {
+ assertEquals(
+ expected = it,
+ actual = event,
+ message = "[Event index=$eventIndex]"
+ )
+ },
+ ifLeft = { it(event) }
+ )
+ ++eventIndex
+ }
+ .launchIn(this)
intents.collect {
- logIfEnabled { "[DISPATCH] Dispatch $it -> $vm" }
+ logIfEnabled(loggingEnabled) { "[DISPATCH] Dispatch $it -> $vm" }
vm.processIntent(it)
}
- delay(delayAfterDispatchingIntents)
- logIfEnabled { "-".repeat(32) }
- stateJob.cancel()
- eventJob.cancel()
- logIfEnabled { "[DONE] states=${states.joinToStringWithIndex()}" }
- logIfEnabled { "[DONE] events=${events.joinToStringWithIndex()}" }
+ return RunResult(
+ states = states,
+ events = events,
+ stateJob = stateJob,
+ eventJob = eventJob,
+ )
+ }
- assertEquals(expectedStates.size, states.size, "States size")
- expectedStates.withIndex().zip(states).forEach { (indexedValue, state) ->
- val (index, exp) = indexedValue
- exp.fold(
- ifRight = {
- assertEquals(
- expected = it,
- actual = state,
- message = "[State index=$index]"
- )
- },
- ifLeft = { it(state) }
- )
- }
+ private suspend fun TestScope.prepare(
+ vm: VM,
+ intents: Flow,
+ loggingEnabled: Boolean,
+ ) {
+ val job1 = vm.viewState.launchIn(this)
+ val job2 = vm.singleEvent.launchIn(this) // ignore events
- assertEquals(expectedEvents.size, events.size, "Events size")
- expectedEvents.withIndex().zip(events).forEach { (indexedValue, event) ->
- val (index, exp) = indexedValue
- exp.fold(
- ifRight = {
- assertEquals(
- expected = it,
- actual = event,
- message = "[Event index=$index]"
- )
- },
- ifLeft = { it(event) }
- )
- }
+ intents
+ .onCompletion {
+ advanceUntilIdle()
+ runCurrent()
- otherAssertions?.invoke()
+ job1.cancelAndJoin()
+ job2.cancelAndJoin()
+ logIfEnabled(loggingEnabled) { DIVIDER }
+ }
+ .collect {
+ vm.processIntent(it)
+ logIfEnabled(loggingEnabled) { "[BEFORE] Dispatch $it -> $vm" }
+ }
}
}
-fun Iterable.mapRight(): List Unit, T>> = map { it.right() }
+private data class RunResult(
+ val states: List,
+ val events: List,
+ val stateJob: Job,
+ val eventJob: Job,
+)
+
+private val DIVIDER = "-".repeat(32)
+
+private fun logIfEnabled(logging: Boolean, s: () -> String) = if (logging) println(s()) else Unit
+
+private fun doAssertions(
+ expectedStates: List Unit, S>>,
+ states: List,
+ expectedEvents: List Unit, E>>,
+ events: List,
+) {
+ expectedStates.withIndex().zip(states).forEach { (indexedValue, state) ->
+ val (index, exp) = indexedValue
+ exp.fold(
+ ifRight = {
+ assertEquals(
+ expected = it,
+ actual = state,
+ message = "[State index=$index]"
+ )
+ },
+ ifLeft = { it(state) }
+ )
+ }
+
+ expectedEvents.withIndex().zip(events).forEach { (indexedValue, event) ->
+ val (index, exp) = indexedValue
+ exp.fold(
+ ifRight = {
+ assertEquals(
+ expected = it,
+ actual = event,
+ message = "[Event index=$index]"
+ )
+ },
+ ifLeft = { it(event) }
+ )
+ }
+}
private fun List.joinToStringWithIndex(): String {
return if (isEmpty()) "[]" else withIndex().joinToString(
diff --git a/renovate.json b/renovate.json
index c7a4fecf..6276c1fa 100644
--- a/renovate.json
+++ b/renovate.json
@@ -3,6 +3,9 @@
"config:base"
],
"commitBodyTable": true,
+ "semanticCommits": "enabled",
+ "labels": ["dependencies"],
+ "assignees": ["hoc081098"],
"packageRules": [
{
"matchPackagePatterns": [
diff --git a/screenshots/Screenshot_01.png b/screenshots/Screenshot_01.png
new file mode 100644
index 00000000..3e1133be
Binary files /dev/null and b/screenshots/Screenshot_01.png differ
diff --git a/screenshots/Screenshot_02.png b/screenshots/Screenshot_02.png
new file mode 100644
index 00000000..55529b40
Binary files /dev/null and b/screenshots/Screenshot_02.png differ
diff --git a/screenshots/Screenshot_03.png b/screenshots/Screenshot_03.png
new file mode 100644
index 00000000..84966300
Binary files /dev/null and b/screenshots/Screenshot_03.png differ
diff --git a/screenshots/Screenshot_04.png b/screenshots/Screenshot_04.png
new file mode 100644
index 00000000..11c95a25
Binary files /dev/null and b/screenshots/Screenshot_04.png differ
diff --git a/screenshots/Screenshot_dark_01.png b/screenshots/Screenshot_dark_01.png
new file mode 100644
index 00000000..65a23ae5
Binary files /dev/null and b/screenshots/Screenshot_dark_01.png differ
diff --git a/screenshots/Screenshot_dark_02.png b/screenshots/Screenshot_dark_02.png
new file mode 100644
index 00000000..28ef675a
Binary files /dev/null and b/screenshots/Screenshot_dark_02.png differ
diff --git a/screenshots/Screenshot_dark_03.png b/screenshots/Screenshot_dark_03.png
new file mode 100644
index 00000000..1ef87f8e
Binary files /dev/null and b/screenshots/Screenshot_dark_03.png differ
diff --git a/screenshots/Screenshot_dark_04.png b/screenshots/Screenshot_dark_04.png
new file mode 100644
index 00000000..d7e479ea
Binary files /dev/null and b/screenshots/Screenshot_dark_04.png differ
diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts
index e09f4e86..6f8799ba 100644
--- a/test-utils/build.gradle.kts
+++ b/test-utils/build.gradle.kts
@@ -3,8 +3,8 @@ plugins {
}
java {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
}
dependencies {
diff --git a/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestCoroutineDispatcherRule.kt b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestCoroutineDispatcherRule.kt
index 1b25e277..f813243c 100644
--- a/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestCoroutineDispatcherRule.kt
+++ b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestCoroutineDispatcherRule.kt
@@ -2,21 +2,25 @@ package com.hoc.flowmvi.test_utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
+/**
+ * A test rule that sets the Main coroutine dispatcher for unit testing.
+ */
@ExperimentalCoroutinesApi
-class TestCoroutineDispatcherRule(val testCoroutineDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
- TestWatcher() {
+class TestCoroutineDispatcherRule(
+ val testDispatcher: TestDispatcher = StandardTestDispatcher(),
+) : TestWatcher() {
override fun starting(description: Description) {
- Dispatchers.setMain(testCoroutineDispatcher)
+ Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
- testCoroutineDispatcher.cleanupTestCoroutines()
}
}
diff --git a/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestDispatchers.kt b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestDispatchers.kt
index 5faa6744..c5fb3d7e 100644
--- a/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestDispatchers.kt
+++ b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/TestDispatchers.kt
@@ -3,10 +3,10 @@ package com.hoc.flowmvi.test_utils
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.TestDispatcher
@ExperimentalCoroutinesApi
-class TestDispatchers(testCoroutineDispatcher: TestCoroutineDispatcher) :
+class TestDispatchers(testCoroutineDispatcher: TestDispatcher) :
CoroutineDispatchers {
override val main: CoroutineDispatcher = testCoroutineDispatcher
override val io: CoroutineDispatcher = testCoroutineDispatcher