diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 109d66d5..686bec5b 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,7 +7,7 @@
-
+
@@ -18,6 +18,7 @@
+
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
index ccdc95c0..23ce7e2e 100644
--- a/.idea/jarRepositories.xml
+++ b/.idea/jarRepositories.xml
@@ -31,5 +31,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 6d4f6e02..57f05c9d 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,10 +1,7 @@
-
-
-
-
-
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 860da66a..22eff8b0 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,5 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0f367f9a..0ab64c98 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -51,10 +51,13 @@ dependencies {
implementation(core)
implementation(featureMain)
implementation(featureAdd)
+ implementation(featureSearch)
implementation(deps.coroutines.android)
implementation(deps.koin.android)
+ debugImplementation(deps.squareup.leakCanary)
+
testImplementation(deps.test.junit)
androidTestImplementation(deps.test.androidxJunit)
androidTestImplementation(deps.test.androidXSspresso)
diff --git a/app/src/main/java/com/hoc/flowmvi/App.kt b/app/src/main/java/com/hoc/flowmvi/App.kt
index e2034f00..6c95e0b2 100644
--- a/app/src/main/java/com/hoc/flowmvi/App.kt
+++ b/app/src/main/java/com/hoc/flowmvi/App.kt
@@ -6,6 +6,7 @@ import com.hoc.flowmvi.data.dataModule
import com.hoc.flowmvi.domain.domainModule
import com.hoc.flowmvi.ui.add.addModule
import com.hoc.flowmvi.ui.main.mainModule
+import com.hoc.flowmvi.ui.search.searchModule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.koin.android.ext.koin.androidContext
@@ -25,8 +26,7 @@ class App : Application() {
startKoin {
androidContext(this@App)
- // TODO: Koin
- androidLogger(level = Level.NONE)
+ androidLogger(if (BuildConfig.DEBUG) Level.DEBUG else Level.NONE)
modules(
coreModule,
@@ -34,6 +34,7 @@ class App : Application() {
domainModule,
mainModule,
addModule,
+ searchModule,
)
}
}
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 135aff92..0bd9c84d 100644
--- a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
+++ b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
@@ -2,14 +2,10 @@ package com.hoc.flowmvi.core
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.core.navigator.Navigator
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
import org.koin.dsl.module
-@FlowPreview
-@ExperimentalCoroutinesApi
val coreModule = module {
single { CoroutineDispatchersImpl() }
- single { NavigatorImpl(add = get()) }
+ single { NavigatorImpl(add = get(), search = get()) }
}
diff --git a/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt b/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt
index 3a919743..130f6c2d 100644
--- a/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt
+++ b/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt
@@ -3,14 +3,15 @@ package com.hoc.flowmvi.core
import android.content.Context
import com.hoc.flowmvi.core.navigator.IntentProviders
import com.hoc.flowmvi.core.navigator.Navigator
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-@ExperimentalCoroutinesApi
-@FlowPreview
class NavigatorImpl(
- private val add: IntentProviders.Add
+ private val add: IntentProviders.Add,
+ private val search: IntentProviders.Search,
) : Navigator {
override fun Context.navigateToAdd() =
startActivity(add.makeIntent(this))
+
+ override fun Context.navigateToSearch() {
+ startActivity(search.makeIntent(this))
+ }
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 1971ce1f..f953cfc1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,9 +9,10 @@ buildscript {
gradlePluginPortal()
}
dependencies {
- classpath("com.android.tools.build:gradle:4.2.0")
+ classpath("com.android.tools.build:gradle:4.2.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.diffplug.spotless:spotless-plugin-gradle:5.12.4")
+ classpath("dev.ahmedmourad.nocopy:nocopy-gradle-plugin:1.4.0")
}
}
@@ -63,8 +64,6 @@ subprojects {
allprojects {
tasks.withType {
kotlinOptions {
- useIR = true
-
val version = JavaVersion.VERSION_1_8.toString()
jvmTarget = version
sourceCompatibility = version
@@ -75,6 +74,7 @@ allprojects {
repositories {
google()
mavenCentral()
+ maven(url = "https://jitpack.io")
}
}
diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt
index c7bdf47b..fc76321d 100644
--- a/buildSrc/src/main/kotlin/deps.kt
+++ b/buildSrc/src/main/kotlin/deps.kt
@@ -6,7 +6,7 @@ import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec
const val ktlintVersion = "0.41.0"
-const val kotlinVersion = "1.5.0"
+const val kotlinVersion = "1.5.10"
object appConfig {
const val applicationId = "com.hoc.flowmvi"
@@ -43,23 +43,25 @@ object deps {
const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2"
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.11.0"
+ const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7"
}
object coroutines {
- private const val version = "1.5.0-RC"
+ private const val version = "1.5.0"
const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
}
object koin {
- private const val version = "3.0.1"
+ private const val version = "3.0.2"
const val core = "io.insert-koin:koin-core:$version"
const val android = "io.insert-koin:koin-android:$version"
}
const val coil = "io.coil-kt:coil:1.2.1"
+ const val viewBindingDelegate = "com.github.hoc081098:ViewBindingDelegate:1.0.0"
object test {
const val junit = "junit:junit:4.13"
@@ -81,3 +83,4 @@ inline val DependencyHandler.core get() = project(":core")
inline val DependencyHandler.data get() = project(":data")
inline val DependencyHandler.featureMain get() = project(":feature-main")
inline val DependencyHandler.featureAdd get() = project(":feature-add")
+inline val DependencyHandler.featureSearch get() = project(":feature-search")
diff --git a/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt b/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt
index e1b1c883..dc6f3377 100644
--- a/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt
+++ b/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt
@@ -1,21 +1 @@
package com.hoc.flowmvi.core
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.hoc081098.flowmvi.core.test", appContext.packageName)
- }
-}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt
deleted file mode 100644
index 406fa174..00000000
--- a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt
+++ /dev/null
@@ -1,201 +0,0 @@
-package com.hoc.flowmvi.core
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.drawable.ColorDrawable
-import android.util.Log
-import android.view.View
-import android.widget.EditText
-import android.widget.Toast
-import androidx.annotation.CheckResult
-import androidx.core.content.ContextCompat
-import androidx.core.widget.doOnTextChanged
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.RecyclerView
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.channelFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicReference
-import kotlin.coroutines.EmptyCoroutineContext
-
-@ExperimentalCoroutinesApi
-fun EditText.firstChange(): Flow {
- return callbackFlow {
- val listener = doOnTextChanged { _, _, _, _ -> trySend(Unit) }
- awaitClose {
- Dispatchers.Main.dispatch(EmptyCoroutineContext) {
- removeTextChangedListener(listener)
- Log.d("###", "removeTextChangedListener $listener ${this@firstChange}")
- }
- }
- }.take(1)
-}
-
-@ExperimentalCoroutinesApi
-@CheckResult
-fun SwipeRefreshLayout.refreshes(): Flow {
- return callbackFlow {
- setOnRefreshListener { trySend(Unit) }
- awaitClose { setOnRefreshListener(null) }
- }
-}
-
-@ExperimentalCoroutinesApi
-@CheckResult
-fun View.clicks(): Flow {
- return callbackFlow {
- setOnClickListener { trySend(it) }
- awaitClose { setOnClickListener(null) }
- }
-}
-
-@ExperimentalCoroutinesApi
-@CheckResult
-fun EditText.textChanges(): Flow {
- return callbackFlow {
- val listener = doOnTextChanged { text, _, _, _ -> trySend(text) }
- addTextChangedListener(listener)
- awaitClose { removeTextChangedListener(listener) }
- }.onStart { emit(text) }
-}
-
-@ExperimentalCoroutinesApi
-fun Flow.flatMapFirst(transform: suspend (value: T) -> Flow): Flow =
- map(transform).flattenFirst()
-
-@ExperimentalCoroutinesApi
-fun Flow>.flattenFirst(): Flow = channelFlow {
- val outerScope = this
- val busy = AtomicBoolean(false)
- collect { inner ->
- if (busy.compareAndSet(false, true)) {
- launch {
- try {
- inner.collect { outerScope.send(it) }
- busy.set(false)
- } catch (e: CancellationException) {
- // cancel outer scope on cancellation exception, too
- outerScope.cancel(e)
- }
- }
- }
- }
-}
-
-private object UNINITIALIZED
-
-fun Flow .withLatestFrom(other: Flow, transform: suspend (A, B) -> R): Flow {
- return flow {
- coroutineScope {
- val latestB = AtomicReference(UNINITIALIZED)
- val outerScope = this
-
- launch {
- try {
- other.collect { latestB.set(it) }
- } catch (e: CancellationException) {
- outerScope.cancel(e) // cancel outer scope on cancellation exception, too
- }
- }
-
- collect { a ->
- val b = latestB.get()
- if (b != UNINITIALIZED) {
- @Suppress("UNCHECKED_CAST")
- emit(transform(a, b as B))
- }
- }
- }
- }
-}
-
-fun Context.toast(text: CharSequence) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
-
-@ExperimentalCoroutinesApi
-suspend fun main() {
- (1..2000).asFlow()
- .onEach { delay(50) }
- .flatMapFirst { v ->
- flow {
- delay(500)
- emit(v)
- }
- }
- .onEach { println("[*] $it") }
- .catch { println("Error $it") }
- .collect()
-}
-
-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)!!
-
- override fun onMove(
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder
- ) = false
-
- override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- val position = viewHolder.bindingAdapterPosition
- if (position != RecyclerView.NO_POSITION) {
- onSwipedCallback(position)
- }
- }
-
- override fun onChildDraw(
- c: Canvas,
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- dX: Float,
- dY: Float,
- actionState: Int,
- isCurrentlyActive: Boolean
- ) {
- super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
- val itemView = viewHolder.itemView
-
- when {
- dX < 0 -> {
- val iconMargin = (itemView.height - iconDelete.intrinsicHeight) / 2
- val iconTop = itemView.top + iconMargin
- val iconBottom = iconTop + iconDelete.intrinsicHeight
-
- val iconRight = itemView.right - iconMargin
- val iconLeft = iconRight - iconDelete.intrinsicWidth
-
- iconDelete.setBounds(iconLeft, iconTop, iconRight, iconBottom)
- background.setBounds(
- itemView.right + dX.toInt() - 8,
- itemView.top,
- itemView.right,
- itemView.bottom
- )
- }
- else -> background.setBounds(0, 0, 0, 0)
- }
- background.draw(c)
- iconDelete.draw(c)
- }
-}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt
new file mode 100644
index 00000000..9b7903c2
--- /dev/null
+++ b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt
@@ -0,0 +1,126 @@
+package com.hoc.flowmvi.core
+
+import android.content.Context
+import android.os.Looper
+import android.util.Log
+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
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.take
+import kotlin.coroutines.EmptyCoroutineContext
+
+internal fun checkMainThread() {
+ check(Looper.myLooper() == Looper.getMainLooper()) {
+ "Expected to be called on the main thread but was " + Thread.currentThread().name
+ }
+}
+
+@ExperimentalCoroutinesApi
+@CheckResult
+fun EditText.firstChange(): Flow {
+ return callbackFlow {
+ checkMainThread()
+
+ val listener = doOnTextChanged { _, _, _, _ -> trySend(Unit) }
+ awaitClose {
+ Dispatchers.Main.dispatch(EmptyCoroutineContext) {
+ removeTextChangedListener(listener)
+ Log.d("###", "removeTextChangedListener $listener ${this@firstChange}")
+ }
+ }
+ }.take(1)
+}
+
+@ExperimentalCoroutinesApi
+@CheckResult
+fun SwipeRefreshLayout.refreshes(): Flow {
+ return callbackFlow {
+ checkMainThread()
+
+ setOnRefreshListener { trySend(Unit) }
+ awaitClose { setOnRefreshListener(null) }
+ }
+}
+
+@ExperimentalCoroutinesApi
+@CheckResult
+fun View.clicks(): Flow {
+ return callbackFlow {
+ checkMainThread()
+
+ setOnClickListener { trySend(it) }
+ awaitClose { setOnClickListener(null) }
+ }
+}
+
+data class SearchViewQueryTextEvent(
+ val view: SearchView,
+ val query: CharSequence,
+ val isSubmitted: Boolean,
+)
+
+@ExperimentalCoroutinesApi
+@CheckResult
+fun SearchView.queryTextEvents(): Flow {
+ return callbackFlow {
+ checkMainThread()
+
+ setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String): Boolean {
+ trySend(
+ SearchViewQueryTextEvent(
+ view = this@queryTextEvents,
+ query = query,
+ isSubmitted = true,
+ )
+ )
+ return false
+ }
+
+ override fun onQueryTextChange(newText: String): Boolean {
+ trySend(
+ SearchViewQueryTextEvent(
+ view = this@queryTextEvents,
+ query = newText,
+ isSubmitted = false,
+ )
+ )
+ return true
+ }
+ })
+
+ awaitClose { setOnQueryTextListener(null) }
+ }.onStart {
+ emit(
+ SearchViewQueryTextEvent(
+ view = this@queryTextEvents,
+ query = query,
+ isSubmitted = false,
+ )
+ )
+ }
+}
+
+@ExperimentalCoroutinesApi
+@CheckResult
+fun EditText.textChanges(): Flow {
+ return callbackFlow {
+ checkMainThread()
+
+ val listener = doOnTextChanged { text, _, _, _ -> trySend(text) }
+ addTextChangedListener(listener)
+ awaitClose { removeTextChangedListener(listener) }
+ }.onStart { emit(text) }
+}
+
+fun Context.toast(text: CharSequence) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt
new file mode 100644
index 00000000..8f9db366
--- /dev/null
+++ b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt
@@ -0,0 +1,112 @@
+package com.hoc.flowmvi.core
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+@ExperimentalCoroutinesApi
+fun Flow.takeUntil(notifier: Flow): Flow = channelFlow {
+ val outerScope = this
+
+ launch {
+ try {
+ notifier.take(1).collect()
+ close()
+ } catch (e: CancellationException) {
+ outerScope.cancel(e) // cancel outer scope on cancellation exception, too
+ }
+ }
+ launch {
+ try {
+ collect { send(it) }
+ } catch (e: CancellationException) {
+ outerScope.cancel(e) // cancel outer scope on cancellation exception, too
+ }
+ }
+}
+
+@ExperimentalCoroutinesApi
+fun Flow.flatMapFirst(transform: suspend (value: T) -> Flow): Flow =
+ map(transform).flattenFirst()
+
+@ExperimentalCoroutinesApi
+fun Flow>.flattenFirst(): Flow = channelFlow {
+ val outerScope = this
+ val busy = AtomicBoolean(false)
+ collect { inner ->
+ if (busy.compareAndSet(false, true)) {
+ launch {
+ try {
+ inner.collect { outerScope.send(it) }
+ busy.set(false)
+ } catch (e: CancellationException) {
+ // cancel outer scope on cancellation exception, too
+ outerScope.cancel(e)
+ }
+ }
+ }
+ }
+}
+
+private object UNINITIALIZED
+
+fun Flow .withLatestFrom(other: Flow, transform: suspend (A, B) -> R): Flow {
+ return flow {
+ coroutineScope {
+ val latestB = AtomicReference(UNINITIALIZED)
+ val outerScope = this
+
+ launch {
+ try {
+ other.collect { latestB.set(it) }
+ } catch (e: CancellationException) {
+ outerScope.cancel(e) // cancel outer scope on cancellation exception, too
+ }
+ }
+
+ collect { a ->
+ val b = latestB.get()
+ if (b != UNINITIALIZED) {
+ @Suppress("UNCHECKED_CAST")
+ emit(transform(a, b as B))
+ }
+ }
+ }
+ }
+}
+
+@ExperimentalCoroutinesApi
+suspend fun main() {
+ (1..100).asFlow()
+ .onEach { delay(50) }
+ .takeUntil(flow { delay(200); emit(Unit) })
+ .collect { println(">>>>> $it") }
+
+ println("Done")
+
+ (1..2000).asFlow()
+ .onEach { delay(50) }
+ .flatMapFirst { v ->
+ flow {
+ delay(500)
+ emit(v)
+ }
+ }
+ .onEach { println("[*] $it") }
+ .catch { println("Error $it") }
+ .collect()
+}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/SwipeLeftToDeleteCallback.kt b/core/src/main/java/com/hoc/flowmvi/core/SwipeLeftToDeleteCallback.kt
new file mode 100644
index 00000000..9c68ce9f
--- /dev/null
+++ b/core/src/main/java/com/hoc/flowmvi/core/SwipeLeftToDeleteCallback.kt
@@ -0,0 +1,64 @@
+package com.hoc.flowmvi.core
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+
+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)!!
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ) = false
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val position = viewHolder.bindingAdapterPosition
+ if (position != RecyclerView.NO_POSITION) {
+ onSwipedCallback(position)
+ }
+ }
+
+ override fun onChildDraw(
+ c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float,
+ dY: Float,
+ actionState: Int,
+ isCurrentlyActive: Boolean
+ ) {
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ val itemView = viewHolder.itemView
+
+ when {
+ dX < 0 -> {
+ val iconMargin = (itemView.height - iconDelete.intrinsicHeight) / 2
+ val iconTop = itemView.top + iconMargin
+ val iconBottom = iconTop + iconDelete.intrinsicHeight
+
+ val iconRight = itemView.right - iconMargin
+ val iconLeft = iconRight - iconDelete.intrinsicWidth
+
+ iconDelete.setBounds(iconLeft, iconTop, iconRight, iconBottom)
+ background.setBounds(
+ itemView.right + dX.toInt() - 8,
+ itemView.top,
+ itemView.right,
+ itemView.bottom
+ )
+ }
+ else -> background.setBounds(0, 0, 0, 0)
+ }
+ background.draw(c)
+ iconDelete.draw(c)
+ }
+}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt b/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt
index 2846ce6d..4749440c 100644
--- a/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt
+++ b/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt
@@ -7,8 +7,14 @@ interface IntentProviders {
interface Add {
fun makeIntent(context: Context): Intent
}
+
+ interface Search {
+ fun makeIntent(context: Context): Intent
+ }
}
interface Navigator {
fun Context.navigateToAdd()
+
+ fun Context.navigateToSearch()
}
diff --git a/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt b/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt
index 41038a9e..dc6f3377 100644
--- a/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt
+++ b/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt
@@ -1,15 +1 @@
package com.hoc.flowmvi.core
-
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
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 b531c2cb..aef3591d 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
@@ -8,7 +8,6 @@ import com.hoc.flowmvi.domain.repository.UserRepository
import com.squareup.moshi.Moshi
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
@@ -23,7 +22,6 @@ private const val BASE_URL = "BASE_URL"
@ExperimentalTime
@ExperimentalCoroutinesApi
-@FlowPreview
val dataModule = module {
single { UserApiService(retrofit = get()) }
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 2ff28c9a..e86547aa 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
@@ -10,7 +10,6 @@ import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.repository.UserRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
-import kotlin.time.milliseconds
@ExperimentalTime
@ExperimentalCoroutinesApi
@@ -54,7 +52,6 @@ internal class UserRepositoryImpl constructor(
}
}
- @FlowPreview
override fun getUsers(): Flow> {
return flow {
val initial = getUsersFromRemote()
@@ -92,6 +89,11 @@ internal class UserRepositoryImpl constructor(
}
}
+ override suspend fun search(query: String) = withContext(dispatchers.io) {
+ delay(400)
+ userApiService.search(query).map(responseToDomain)
+ }
+
companion object {
private val avatarUrls =
(0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
diff --git a/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
index c355bb35..2c7cd3c7 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
@@ -8,6 +8,7 @@ import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
+import retrofit2.http.Query
internal interface UserApiService {
@GET("users")
@@ -20,6 +21,9 @@ internal interface UserApiService {
@POST("users")
suspend fun add(@Body user: UserBody): UserResponse
+ @GET("users/search")
+ suspend fun search(@Query("q") query: String): List
+
companion object {
operator fun invoke(retrofit: Retrofit) = retrofit.create()
}
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 93322268..9f9691a6 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
@@ -4,6 +4,7 @@ import com.hoc.flowmvi.domain.usecase.AddUserUseCase
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.domain.usecase.SearchUsersUseCase
import org.koin.dsl.module
val domainModule = module {
@@ -14,4 +15,6 @@ val domainModule = module {
factory { RemoveUserUseCase(userRepository = get()) }
factory { AddUserUseCase(userRepository = get()) }
+
+ factory { SearchUsersUseCase(userRepository = get()) }
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
index 16fc7496..e3efec74 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
@@ -11,4 +11,6 @@ interface UserRepository {
suspend fun remove(user: User)
suspend fun add(user: User)
+
+ suspend fun search(query: String): List
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt
new file mode 100644
index 00000000..bc7c4db5
--- /dev/null
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt
@@ -0,0 +1,8 @@
+package com.hoc.flowmvi.domain.usecase
+
+import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserRepository
+
+class SearchUsersUseCase(private val userRepository: UserRepository) {
+ suspend operator fun invoke(query: String): List = userRepository.search(query)
+}
diff --git a/feature-add/build.gradle.kts b/feature-add/build.gradle.kts
index f4ca0597..08ac6696 100644
--- a/feature-add/build.gradle.kts
+++ b/feature-add/build.gradle.kts
@@ -50,4 +50,5 @@ dependencies {
implementation(deps.coroutines.core)
implementation(deps.koin.android)
+ implementation(deps.viewBindingDelegate)
}
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 6956eaa8..fb9b34df 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
@@ -17,25 +17,22 @@ import com.hoc.flowmvi.core.navigator.IntentProviders
import com.hoc.flowmvi.core.textChanges
import com.hoc.flowmvi.core.toast
import com.hoc.flowmvi.ui.add.databinding.ActivityAddBinding
+import com.hoc081098.viewbindingdelegate.viewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import org.koin.androidx.viewmodel.ext.android.stateViewModel
-import kotlin.LazyThreadSafetyMode.NONE
-@FlowPreview
@ExperimentalCoroutinesApi
-class AddActivity : AppCompatActivity() {
+class AddActivity : AppCompatActivity(R.layout.activity_add) {
private val addVM by stateViewModel()
- private val addBinding by lazy(NONE) { ActivityAddBinding.inflate(layoutInflater) }
+ private val addBinding by viewBinding()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(addBinding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setupViews()
@@ -130,7 +127,8 @@ class AddActivity : AppCompatActivity() {
}
}
- private fun intents(): Flow = addBinding.run {
+ @Suppress("NOTHING_TO_INLINE")
+ private inline fun intents(): Flow = addBinding.run {
merge(
emailEditText
.editText!!
@@ -162,8 +160,6 @@ class AddActivity : AppCompatActivity() {
)
}
- @ExperimentalCoroutinesApi
- @FlowPreview
internal class IntentProvider : IntentProviders.Add {
override fun makeIntent(context: Context): Intent =
Intent(context, AddActivity::class.java)
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
index bd9d8abe..0bc8d7df 100644
--- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
@@ -27,6 +27,7 @@ internal data class ViewState(
lastName: String?
) = ViewState(
errors = emptySet(),
+
isLoading = false,
emailChanged = false,
firstNameChanged = false,
@@ -38,26 +39,26 @@ internal data class ViewState(
}
}
-internal sealed class ViewIntent {
- data class EmailChanged(val email: String?) : ViewIntent()
- data class FirstNameChanged(val firstName: String?) : ViewIntent()
- data class LastNameChanged(val lastName: String?) : ViewIntent()
+internal sealed interface ViewIntent {
+ data class EmailChanged(val email: String?) : ViewIntent
+ data class FirstNameChanged(val firstName: String?) : ViewIntent
+ data class LastNameChanged(val lastName: String?) : ViewIntent
- object Submit : ViewIntent()
+ object Submit : ViewIntent
- object EmailChangedFirstTime : ViewIntent()
- object FirstNameChangedFirstTime : ViewIntent()
- object LastNameChangedFirstTime : ViewIntent()
+ object EmailChangedFirstTime : ViewIntent
+ object FirstNameChangedFirstTime : ViewIntent
+ object LastNameChangedFirstTime : ViewIntent
}
-internal sealed class PartialStateChange {
- abstract fun reduce(viewState: ViewState): ViewState
+internal sealed interface PartialStateChange {
+ fun reduce(viewState: ViewState): ViewState
- data class ErrorsChanged(val errors: Set) : PartialStateChange() {
+ data class ErrorsChanged(val errors: Set) : PartialStateChange {
override fun reduce(viewState: ViewState) = viewState.copy(errors = errors)
}
- sealed class AddUser : PartialStateChange() {
+ sealed class AddUser : PartialStateChange {
object Loading : AddUser()
data class AddUserSuccess(val user: User) : AddUser()
data class AddUserFailure(val user: User, val throwable: Throwable) : AddUser()
@@ -71,7 +72,7 @@ internal sealed class PartialStateChange {
}
}
- sealed class FirstChange : PartialStateChange() {
+ sealed class FirstChange : PartialStateChange {
object EmailChangedFirstTime : FirstChange()
object FirstNameChangedFirstTime : FirstChange()
object LastNameChangedFirstTime : FirstChange()
@@ -85,7 +86,7 @@ internal sealed class PartialStateChange {
}
}
- sealed class FormValueChange : PartialStateChange() {
+ sealed class FormValueChange : PartialStateChange {
override fun reduce(viewState: ViewState): ViewState {
return when (this) {
is EmailChanged -> viewState.copy(email = email)
@@ -100,7 +101,7 @@ internal sealed class PartialStateChange {
}
}
-internal sealed class SingleEvent {
- data class AddUserSuccess(val user: User) : SingleEvent()
- data class AddUserFailure(val user: User, val throwable: Throwable) : SingleEvent()
+internal sealed interface SingleEvent {
+ data class AddUserSuccess(val user: User) : SingleEvent
+ data class AddUserFailure(val user: User, val throwable: Throwable) : SingleEvent
}
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 5fb56d91..227df3f6 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
@@ -2,12 +2,10 @@ package com.hoc.flowmvi.ui.add
import com.hoc.flowmvi.core.navigator.IntentProviders
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
@ExperimentalCoroutinesApi
-@FlowPreview
val addModule = module {
viewModel { params ->
AddVM(
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
index c27a91d9..c2a762da 100644
--- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
@@ -11,7 +11,6 @@ import com.hoc.flowmvi.core.withLatestFrom
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.usecase.AddUserUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
-@FlowPreview
@ExperimentalCoroutinesApi
internal class AddVM(
private val addUser: AddUserUseCase,
@@ -44,7 +42,7 @@ internal class AddVM(
val viewState: StateFlow
val singleEvent: Flow get() = _eventChannel.receiveAsFlow()
- suspend fun processIntent(intent: ViewIntent) = _intentFlow.emit(intent)
+ fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent)
init {
val initialVS = ViewState.initial(
diff --git a/feature-main/build.gradle.kts b/feature-main/build.gradle.kts
index 1505c100..90715ab6 100644
--- a/feature-main/build.gradle.kts
+++ b/feature-main/build.gradle.kts
@@ -53,4 +53,5 @@ dependencies {
implementation(deps.coroutines.core)
implementation(deps.koin.android)
implementation(deps.coil)
+ implementation(deps.viewBindingDelegate)
}
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 9d594ded..73116d4b 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
@@ -18,9 +18,11 @@ import com.hoc.flowmvi.core.navigator.Navigator
import com.hoc.flowmvi.core.refreshes
import com.hoc.flowmvi.core.toast
import com.hoc.flowmvi.ui.main.databinding.ActivityMainBinding
+import com.hoc081098.viewbindingdelegate.viewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
@@ -29,22 +31,20 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
-import kotlin.LazyThreadSafetyMode.NONE
@FlowPreview
@ExperimentalCoroutinesApi
-class MainActivity : AppCompatActivity() {
+class MainActivity : AppCompatActivity(R.layout.activity_main) {
private val mainVM by viewModel()
private val navigator by inject()
private val userAdapter = UserAdapter()
- private val mainBinding by lazy(NONE) { ActivityMainBinding.inflate(layoutInflater) }
+ private val mainBinding by viewBinding()
private val removeChannel = Channel(Channel.BUFFERED)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(mainBinding.root)
setupViews()
bindVM(mainVM)
@@ -56,14 +56,16 @@ class MainActivity : AppCompatActivity() {
navigator.run { navigateToAdd() }
true
}
+ R.id.search_action -> {
+ navigator.run { navigateToSearch() }
+ true
+ }
else -> super.onOptionsItemSelected(item)
}
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- return true
- }
+ override fun onCreateOptionsMenu(menu: Menu?) =
+ menuInflater.inflate(R.menu.menu_main, menu).let { true }
private fun setupViews() {
mainBinding.usersRecycler.run {
@@ -96,7 +98,8 @@ class MainActivity : AppCompatActivity() {
.launchIn(lifecycleScope)
}
- private fun intents() = merge(
+ @Suppress("NOTHING_TO_INLINE")
+ private inline fun intents(): Flow = merge(
flowOf(ViewIntent.Initial),
mainBinding.swipeRefreshLayout.refreshes().map { ViewIntent.Refresh },
mainBinding.retryButton.clicks().map { ViewIntent.Retry },
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
index c91a89d9..f26d3188 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
@@ -28,11 +28,11 @@ internal data class UserItem(
)
}
-internal sealed class ViewIntent {
- object Initial : ViewIntent()
- object Refresh : ViewIntent()
- object Retry : ViewIntent()
- data class RemoveUser(val user: UserItem) : ViewIntent()
+internal sealed interface ViewIntent {
+ object Initial : ViewIntent
+ object Refresh : ViewIntent
+ object Retry : ViewIntent
+ data class RemoveUser(val user: UserItem) : ViewIntent
}
internal data class ViewState(
@@ -51,10 +51,10 @@ internal data class ViewState(
}
}
-internal sealed class PartialChange {
- abstract fun reduce(vs: ViewState): ViewState
+internal sealed interface PartialChange {
+ fun reduce(vs: ViewState): ViewState
- sealed class GetUser : PartialChange() {
+ sealed class GetUser : PartialChange {
override fun reduce(vs: ViewState): ViewState {
return when (this) {
Loading -> vs.copy(
@@ -78,7 +78,7 @@ internal sealed class PartialChange {
data class Error(val error: Throwable) : GetUser()
}
- sealed class Refresh : PartialChange() {
+ sealed class Refresh : PartialChange {
override fun reduce(vs: ViewState): ViewState {
return when (this) {
is Success -> vs.copy(isRefreshing = false)
@@ -92,7 +92,7 @@ internal sealed class PartialChange {
data class Failure(val error: Throwable) : Refresh()
}
- sealed class RemoveUser : PartialChange() {
+ sealed class RemoveUser : PartialChange {
data class Success(val user: UserItem) : RemoveUser()
data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
@@ -100,16 +100,16 @@ internal sealed class PartialChange {
}
}
-internal sealed class SingleEvent {
- sealed class Refresh : SingleEvent() {
- object Success : Refresh()
- data class Failure(val error: Throwable) : Refresh()
+internal sealed interface SingleEvent {
+ sealed interface Refresh : SingleEvent {
+ object Success : Refresh
+ data class Failure(val error: Throwable) : Refresh
}
- data class GetUsersError(val error: Throwable) : SingleEvent()
+ data class GetUsersError(val error: Throwable) : SingleEvent
- sealed class RemoveUser : SingleEvent() {
- data class Success(val user: UserItem) : RemoveUser()
- data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
+ sealed interface RemoveUser : SingleEvent {
+ data class Success(val user: UserItem) : RemoveUser
+ data class Failure(val user: UserItem, val error: Throwable) : RemoveUser
}
}
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
index 0082650c..2b6a3e5c 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
@@ -46,7 +46,7 @@ internal class MainVM(
val viewState: StateFlow
val singleEvent: Flow get() = _eventChannel.receiveAsFlow()
- suspend fun processIntent(intent: ViewIntent) = _intentFlow.emit(intent)
+ fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent)
init {
val initialVS = ViewState.initial()
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
index 35e1b948..b2130ee4 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
@@ -1,6 +1,5 @@
package com.hoc.flowmvi.ui.main
-import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -8,6 +7,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import com.hoc.flowmvi.ui.main.databinding.ItemRecyclerUserBinding
+import com.hoc081098.viewbindingdelegate.inflateViewBinding
internal class UserAdapter :
ListAdapter(
@@ -19,14 +19,8 @@ internal class UserAdapter :
}
) {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
- val binding = ItemRecyclerUserBinding.inflate(
- LayoutInflater.from(parent.context),
- parent,
- false
- )
- return VH(binding)
- }
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(parent inflateViewBinding false)
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position))
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
new file mode 100644
index 00000000..07b76d62
--- /dev/null
+++ b/feature-main/src/main/res/drawable/ic_baseline_search_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature-main/src/main/res/menu/menu_main.xml b/feature-main/src/main/res/menu/menu_main.xml
index 910d9b6c..fb3ec306 100644
--- a/feature-main/src/main/res/menu/menu_main.xml
+++ b/feature-main/src/main/res/menu/menu_main.xml
@@ -8,4 +8,10 @@
android:title="Add"
app:showAsAction="always" />
+
+
diff --git a/feature-search/.gitignore b/feature-search/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/feature-search/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/feature-search/build.gradle.kts b/feature-search/build.gradle.kts
new file mode 100644
index 00000000..501782d8
--- /dev/null
+++ b/feature-search/build.gradle.kts
@@ -0,0 +1,58 @@
+plugins {
+ androidLib
+ kotlinAndroid
+ id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin")
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+
+ buildFeatures { viewBinding = true }
+}
+
+dependencies {
+ api(domain)
+ api(core)
+
+ implementation(deps.androidx.appCompat)
+ implementation(deps.androidx.coreKtx)
+
+ implementation(deps.lifecycle.viewModelKtx)
+ implementation(deps.lifecycle.runtimeKtx)
+
+ implementation(deps.androidx.recyclerView)
+ implementation(deps.androidx.constraintLayout)
+ implementation(deps.androidx.swipeRefreshLayout)
+ implementation(deps.androidx.material)
+
+ implementation(deps.coroutines.core)
+ implementation(deps.koin.android)
+ implementation(deps.coil)
+ implementation(deps.viewBindingDelegate)
+}
diff --git a/feature-search/consumer-rules.pro b/feature-search/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature-search/proguard-rules.pro b/feature-search/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/feature-search/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/feature-search/src/androidTest/java/com/hoc/flowmvi/ui/search/ExampleInstrumentedTest.kt b/feature-search/src/androidTest/java/com/hoc/flowmvi/ui/search/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..77fa3853
--- /dev/null
+++ b/feature-search/src/androidTest/java/com/hoc/flowmvi/ui/search/ExampleInstrumentedTest.kt
@@ -0,0 +1 @@
+package com.hoc.flowmvi.ui.search
diff --git a/feature-search/src/main/AndroidManifest.xml b/feature-search/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c7eee54e
--- /dev/null
+++ b/feature-search/src/main/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000..0794df6d
--- /dev/null
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt
@@ -0,0 +1,128 @@
+package com.hoc.flowmvi.ui.search
+
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SearchView
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import com.hoc.flowmvi.core.SearchViewQueryTextEvent
+import com.hoc.flowmvi.core.clicks
+import com.hoc.flowmvi.core.collectIn
+import com.hoc.flowmvi.core.navigator.IntentProviders
+import com.hoc.flowmvi.core.queryTextEvents
+import com.hoc.flowmvi.core.toast
+import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding
+import com.hoc081098.viewbindingdelegate.viewBinding
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.consumeAsFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlin.time.ExperimentalTime
+
+@ExperimentalCoroutinesApi
+@FlowPreview
+@ExperimentalTime
+class SearchActivity : AppCompatActivity(R.layout.activity_search) {
+ private val binding by viewBinding()
+ private val vm by viewModel()
+
+ private val searchViewQueryTextEventChannel = Channel()
+ private val searchAdapter = SearchAdapter()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+ setupViews()
+ bindVM()
+ }
+
+ private fun bindVM() {
+ vm.viewState.collectIn(this) { viewState ->
+ searchAdapter.submitList(viewState.users)
+
+ binding.run {
+ textQuery.isInvisible = viewState.isLoading || viewState.query.isBlank()
+ textQuery.text = "Search results for '${viewState.query}'"
+
+ errorGroup.isVisible = viewState.error !== null
+ errorMessageTextView.text = viewState.error?.message
+
+ progressBar.isVisible = viewState.isLoading
+ }
+ }
+
+ vm.singleEvent.collectIn(this) { event ->
+ when (event) {
+ is SingleEvent.SearchFailure -> toast("Failed to search")
+ }
+ }
+
+ intents()
+ .onEach { vm.processIntent(it) }
+ .launchIn(lifecycleScope)
+ }
+
+ @Suppress("NOTHING_TO_INLINE")
+ private inline fun intents(): Flow = merge(
+ searchViewQueryTextEventChannel
+ .consumeAsFlow()
+ .onEach { Log.d("SearchActivity", "Query $it") }
+ .map { ViewIntent.Search(it.query.toString()) },
+ binding.retryButton.clicks().map { ViewIntent.Retry },
+ )
+
+ private fun setupViews() {
+ binding.run {
+ usersRecycler.run {
+ setHasFixedSize(true)
+ layoutManager = GridLayoutManager(
+ context,
+ if (context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 3 else 4,
+ )
+ adapter = searchAdapter
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> finish().let { true }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_search, menu)
+
+ (menu.findItem(R.id.action_search)!!.actionView as SearchView).run {
+ isIconified = false
+ queryHint = "Search user..."
+
+ queryTextEvents()
+ .onEach { searchViewQueryTextEventChannel.send(it) }
+ .launchIn(lifecycleScope)
+ }
+
+ return true
+ }
+
+ internal class IntentProvider : IntentProviders.Search {
+ override fun makeIntent(context: Context): Intent =
+ Intent(context, SearchActivity::class.java)
+ }
+}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchAdapter.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchAdapter.kt
new file mode 100644
index 00000000..2e2dfe4b
--- /dev/null
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchAdapter.kt
@@ -0,0 +1,35 @@
+package com.hoc.flowmvi.ui.search
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import com.hoc.flowmvi.ui.search.databinding.ItemRecyclerSearchUserBinding
+import com.hoc081098.viewbindingdelegate.inflateViewBinding
+
+internal class SearchAdapter :
+ ListAdapter(object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: UserItem, newItem: UserItem) = oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: UserItem, newItem: UserItem) = oldItem == newItem
+ }) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ VH(parent inflateViewBinding false)
+
+ override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position))
+
+ class VH(private val binding: ItemRecyclerSearchUserBinding) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: UserItem) {
+ binding.run {
+ nameTextView.text = item.fullName
+ emailTextView.text = item.email
+ avatarImage.load(item.avatar) {
+ crossfade(200)
+ placeholder(R.drawable.ic_baseline_person_24)
+ error(R.drawable.ic_baseline_person_24)
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..235fd122
--- /dev/null
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt
@@ -0,0 +1,73 @@
+package com.hoc.flowmvi.ui.search
+
+import com.hoc.flowmvi.domain.entity.User
+import dev.ahmedmourad.nocopy.annotations.NoCopy
+
+@Suppress("DataClassPrivateConstructor")
+@NoCopy
+internal data class UserItem private constructor(
+ val id: String,
+ val email: String,
+ val avatar: String,
+ val fullName: String,
+) {
+ companion object Factory {
+ fun from(domain: User): UserItem {
+ return UserItem(
+ id = domain.id,
+ email = domain.email,
+ avatar = domain.avatar,
+ fullName = "${domain.firstName} ${domain.lastName}",
+ )
+ }
+ }
+}
+
+internal sealed interface ViewIntent {
+ data class Search(val query: String) : ViewIntent
+ object Retry : ViewIntent
+}
+
+internal data class ViewState(
+ val users: List,
+ val isLoading: Boolean,
+ val error: Throwable?,
+ val query: String,
+) {
+ companion object Factory {
+ fun initial(): ViewState {
+ return ViewState(
+ users = emptyList(),
+ isLoading = false,
+ error = null,
+ query = "",
+ )
+ }
+ }
+}
+
+internal sealed interface PartialStateChange {
+ object Loading : PartialStateChange
+ data class Success(val users: List, val query: String) : PartialStateChange
+ data class Failure(val error: Throwable, val query: String) : PartialStateChange
+
+ fun reduce(state: ViewState) = when (this) {
+ is Failure -> state.copy(
+ isLoading = false,
+ error = error,
+ query = query,
+ users = emptyList()
+ )
+ Loading -> state.copy(isLoading = true, error = null)
+ is Success -> state.copy(
+ isLoading = false,
+ error = null,
+ users = users,
+ query = query,
+ )
+ }
+}
+
+internal sealed interface SingleEvent {
+ data class SearchFailure(val error: Throwable) : SingleEvent
+}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt
new file mode 100644
index 00000000..1757ccd7
--- /dev/null
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt
@@ -0,0 +1,17 @@
+package com.hoc.flowmvi.ui.search
+
+import com.hoc.flowmvi.core.navigator.IntentProviders
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+import kotlin.time.ExperimentalTime
+
+@ExperimentalCoroutinesApi
+@FlowPreview
+@ExperimentalTime
+val searchModule = module {
+ single { SearchActivity.IntentProvider() }
+
+ viewModel { SearchVM(searchUsersUseCase = get()) }
+}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt
new file mode 100644
index 00000000..7e0a76ea
--- /dev/null
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt
@@ -0,0 +1,103 @@
+package com.hoc.flowmvi.ui.search
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.hoc.flowmvi.core.flatMapFirst
+import com.hoc.flowmvi.core.takeUntil
+import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+
+@FlowPreview
+@ExperimentalTime
+@ExperimentalCoroutinesApi
+internal class SearchVM(
+ private val searchUsersUseCase: SearchUsersUseCase,
+) : ViewModel() {
+ private val _intentFlow = MutableSharedFlow(extraBufferCapacity = 64)
+ private val _singleEvent = Channel(Channel.BUFFERED)
+
+ val viewState: StateFlow
+ val singleEvent: Flow get() = _singleEvent.receiveAsFlow()
+ fun processIntent(intent: ViewIntent) = _intentFlow.tryEmit(intent)
+
+ init {
+ val initialVS = ViewState.initial()
+
+ viewState = _intentFlow
+ .toPartialStateChangesFlow()
+ .sendSingleEvent()
+ .scan(initialVS) { state, change -> change.reduce(state) }
+ .catch { Log.d("###", "[SEARCH_VM] Throwable: $it") }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS)
+ }
+
+ private fun Flow.toPartialStateChangesFlow(): Flow {
+ val executeSearch: suspend (String) -> Flow = { query: String ->
+ flow { emit(searchUsersUseCase(query)) }
+ .map {
+ @Suppress("USELESS_CAST")
+ PartialStateChange.Success(
+ it.map(UserItem::from),
+ query,
+ ) as PartialStateChange
+ }
+ .onStart { emit(PartialStateChange.Loading) }
+ .catch { emit(PartialStateChange.Failure(it, query)) }
+ }
+
+ val queryFlow = filterIsInstance()
+ .debounce(Duration.milliseconds(400))
+ .map { it.query }
+ .filter { it.isNotBlank() }
+ .distinctUntilChanged()
+ .shareIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ )
+
+ return merge(
+ queryFlow.flatMapLatest(executeSearch),
+ filterIsInstance()
+ .flatMapFirst {
+ viewState.value.let { vs ->
+ if (vs.error !== null) executeSearch(vs.query).takeUntil(queryFlow)
+ else emptyFlow()
+ }
+ },
+ )
+ }
+
+ private fun Flow.sendSingleEvent(): Flow =
+ onEach { change ->
+ when (change) {
+ is PartialStateChange.Failure -> _singleEvent.send(SingleEvent.SearchFailure(change.error))
+ PartialStateChange.Loading -> return@onEach
+ is PartialStateChange.Success -> return@onEach
+ }
+ }
+}
diff --git a/feature-search/src/main/res/drawable/ic_baseline_person_24.xml b/feature-search/src/main/res/drawable/ic_baseline_person_24.xml
new file mode 100644
index 00000000..febde3ff
--- /dev/null
+++ b/feature-search/src/main/res/drawable/ic_baseline_person_24.xml
@@ -0,0 +1,10 @@
+
+
+
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
new file mode 100644
index 00000000..07b76d62
--- /dev/null
+++ b/feature-search/src/main/res/drawable/ic_baseline_search_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/feature-search/src/main/res/layout/activity_search.xml b/feature-search/src/main/res/layout/activity_search.xml
new file mode 100644
index 00000000..56359fed
--- /dev/null
+++ b/feature-search/src/main/res/layout/activity_search.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000..4431f021
--- /dev/null
+++ b/feature-search/src/main/res/layout/item_recycler_search_user.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/feature-search/src/main/res/menu/menu_search.xml b/feature-search/src/main/res/menu/menu_search.xml
new file mode 100644
index 00000000..2f131243
--- /dev/null
+++ b/feature-search/src/main/res/menu/menu_search.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/feature-search/src/main/res/values/strings.xml b/feature-search/src/main/res/values/strings.xml
new file mode 100644
index 00000000..7abc06d3
--- /dev/null
+++ b/feature-search/src/main/res/values/strings.xml
@@ -0,0 +1 @@
+
diff --git a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/ExampleUnitTest.kt b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/ExampleUnitTest.kt
new file mode 100644
index 00000000..77fa3853
--- /dev/null
+++ b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/ExampleUnitTest.kt
@@ -0,0 +1 @@
+package com.hoc.flowmvi.ui.search
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 0a4f0cad..9f72cc26 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16dde09a..48b6f148 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -3,6 +3,7 @@ rootProject.name = "MVI Coroutines Flow"
include(":app")
include(":feature-main")
include(":feature-add")
+include(":feature-search")
include(":domain")
include(":data")
include(":core")