diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml
index bef0f956..98919475 100644
--- a/.github/workflows/qodana.yml
+++ b/.github/workflows/qodana.yml
@@ -1,21 +1,21 @@
-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: 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
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/misc.xml b/.idea/misc.xml
index 67a156c7..bd2b2cb7 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -14,11 +14,11 @@
-
+
-
-
+
+
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/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/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/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/FlowBinding.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/FlowBinding.kt
index 105f0069..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
@@ -8,7 +8,6 @@ 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
@@ -23,7 +22,6 @@ internal fun checkMainThread() {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun EditText.firstChange(): Flow {
return callbackFlow {
@@ -39,7 +37,6 @@ fun EditText.firstChange(): Flow {
}.take(1)
}
-@ExperimentalCoroutinesApi
@CheckResult
fun SwipeRefreshLayout.refreshes(): Flow {
return callbackFlow {
@@ -50,7 +47,6 @@ fun SwipeRefreshLayout.refreshes(): Flow {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun View.clicks(): Flow {
return callbackFlow {
@@ -67,7 +63,6 @@ data class SearchViewQueryTextEvent(
val isSubmitted: Boolean,
)
-@ExperimentalCoroutinesApi
@CheckResult
fun SearchView.queryTextEvents(): Flow {
return callbackFlow {
@@ -109,7 +104,6 @@ fun SearchView.queryTextEvents(): Flow {
}
}
-@ExperimentalCoroutinesApi
@CheckResult
fun EditText.textChanges(): Flow {
return callbackFlow {
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/themes.xml b/core-ui/src/main/res/values/themes.xml
index 01983e75..e010bd31 100644
--- a/core-ui/src/main/res/values/themes.xml
+++ b/core-ui/src/main/res/values/themes.xml
@@ -1,30 +1,31 @@
-
+
+
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 f1dc6ff9..a70eba5f 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -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 ae3de8d9..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,12 +15,15 @@ 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
@@ -32,6 +34,7 @@ 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 = 500.milliseconds,
- 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/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
index 7187582a..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,6 +26,7 @@ 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
@@ -98,6 +99,7 @@ private val USERS = listOf(
private val VALID_NEL_USERS = USERS.map(User::validNel)
+@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalTime
class UserRepositoryImplTest {
@@ -165,7 +167,7 @@ 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()) }
}
@@ -315,7 +317,7 @@ 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()) }
}
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/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 5a145e88..69174df3 100644
--- a/feature-add/src/main/res/layout/activity_add.xml
+++ b/feature-add/src/main/res/layout/activity_add.xml
@@ -48,8 +48,7 @@
android:layout_marginEnd="16dp"
android:fontFamily="@font/noto_sans"
android:inputType="textEmailAddress"
- android:singleLine="true"
- android:textSize="16sp" />
+ 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/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_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 5cf781b9..83ae9793 100644
--- a/feature-main/src/main/res/layout/activity_main.xml
+++ b/feature-main/src/main/res/layout/activity_main.xml
@@ -22,23 +22,22 @@
-
-
-
+ android:layout_height="wrap_content">
-
-
-
-
+
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/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 1b23af7e..339ac6b5 100644
--- a/feature-search/src/main/res/layout/activity_search.xml
+++ b/feature-search/src/main/res/layout/activity_search.xml
@@ -1,94 +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 41fa3a90..c4bacf5c 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,62 @@
-
-
-
-
+ android:background="?attr/colorSurface"
+ 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/gradlew.bat b/gradlew.bat
index ac1b06f9..107acd32 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,89 +1,89 @@
-@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
+@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/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-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 @@
-
+