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 @@ [![Qodana](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/qodana.yml/badge.svg)](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/qodana.yml) [![Validate Gradle Wrapper](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/gradle-wrapper-validation.yml/badge.svg)](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/gradle-wrapper-validation.yml) [![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) -[![Kotlin](https://img.shields.io/badge/kotlin-1.5.31-blue.svg?logo=kotlin)](http://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/kotlin-1.6.10-blue.svg?logo=kotlin)](http://kotlinlang.org) [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2FKotlin-Android-Open-Source%2FMVI-Coroutines-Flow&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) [![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](https://opensource.org/licenses/MIT) +[![Gitter](https://badges.gitter.im/Kotlin-Android-Open-Source/community.svg)](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 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> - + 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