From 8f3982a95873bd868e7cb5a039b943afc0c6cce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Tue, 11 May 2021 15:21:38 +0700 Subject: [PATCH 01/26] init --- .idea/gradle.xml | 1 + .idea/kotlinc.xml | 7 +-- feature-search/.gitignore | 1 + feature-search/build.gradle.kts | 56 +++++++++++++++++++ feature-search/consumer-rules.pro | 0 feature-search/proguard-rules.pro | 21 +++++++ .../ui/search/ExampleInstrumentedTest.kt | 24 ++++++++ feature-search/src/main/AndroidManifest.xml | 9 +++ .../hoc/flowmvi/ui/search/SearchActivity.kt | 8 +++ .../src/main/res/layout/activity_search.xml | 9 +++ .../src/main/res/values/strings.xml | 1 + .../hoc/flowmvi/ui/search/ExampleUnitTest.kt | 17 ++++++ settings.gradle.kts | 1 + 13 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 feature-search/.gitignore create mode 100644 feature-search/build.gradle.kts create mode 100644 feature-search/consumer-rules.pro create mode 100644 feature-search/proguard-rules.pro create mode 100644 feature-search/src/androidTest/java/com/hoc/flowmvi/ui/search/ExampleInstrumentedTest.kt create mode 100644 feature-search/src/main/AndroidManifest.xml create mode 100644 feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt create mode 100644 feature-search/src/main/res/layout/activity_search.xml create mode 100644 feature-search/src/main/res/values/strings.xml create mode 100644 feature-search/src/test/java/com/hoc/flowmvi/ui/search/ExampleUnitTest.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 109d66d5..2fe111bb 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -18,6 +18,7 @@ - + diff --git a/build.gradle.kts b/build.gradle.kts index a49e2969..b8aca77f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ 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") } 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 index 76c69baf..eeb029af 100644 --- 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 @@ -91,23 +91,25 @@ fun SearchView.queryTextEvents(): Flow { setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { - return trySend( + trySend( SearchViewQueryTextEvent( view = this@queryTextEvents, query = query, isSubmitted = true, ) - ).isSuccess + ) + return false } override fun onQueryTextChange(newText: String): Boolean { - return trySend( + trySend( SearchViewQueryTextEvent( view = this@queryTextEvents, query = newText, isSubmitted = false, ) - ).isSuccess + ) + return true } }) 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 17e9bf25..fafcd057 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 @@ -62,6 +62,7 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { private inline fun intents(): Flow = merge( searchViewQueryTextEventChannel .consumeAsFlow() + .onEach { Log.d("SearchActivity", "Query $it") } .map { ViewIntent.Search(it.query.toString()) } ) @@ -83,7 +84,7 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { queryHint = "Search user..." queryTextEvents() - .onEach { searchViewQueryTextEventChannel.trySend(it) } + .onEach { searchViewQueryTextEventChannel.send(it) } .launchIn(lifecycleScope) } From 06227997c2aa7e785fdff928452228519427e5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Fri, 21 May 2021 11:49:12 +0700 Subject: [PATCH 16/26] wip --- .../src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fafcd057..38d97d76 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 @@ -34,7 +34,7 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { private val binding by viewBinding() private val vm by viewModel() - private val searchViewQueryTextEventChannel = Channel(Channel.BUFFERED) + private val searchViewQueryTextEventChannel = Channel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From 6a9cccf35c99effed748a93279b23805ad4ca870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Fri, 21 May 2021 11:57:09 +0700 Subject: [PATCH 17/26] wip --- .../src/main/res/layout/activity_search.xml | 84 ++++++++++++++++++- .../main/res/layout/item_recycler_user.xml | 63 ++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 feature-search/src/main/res/layout/item_recycler_user.xml diff --git a/feature-search/src/main/res/layout/activity_search.xml b/feature-search/src/main/res/layout/activity_search.xml index fa049cca..bb365f6f 100644 --- a/feature-search/src/main/res/layout/activity_search.xml +++ b/feature-search/src/main/res/layout/activity_search.xml @@ -1,10 +1,86 @@ + + + android:layout_height="match_parent"> + + + + + + + + + + + + diff --git a/feature-search/src/main/res/layout/item_recycler_user.xml b/feature-search/src/main/res/layout/item_recycler_user.xml new file mode 100644 index 00000000..1465107a --- /dev/null +++ b/feature-search/src/main/res/layout/item_recycler_user.xml @@ -0,0 +1,63 @@ + + + + + + + + + + From cb9f0948bf1e99866ca1cbe0492ef039bd56892a Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Thu, 27 May 2021 01:28:05 +0700 Subject: [PATCH 18/26] wip --- .idea/compiler.xml | 2 +- .idea/misc.xml | 4 +- build.gradle.kts | 1 + feature-search/build.gradle.kts | 1 + .../hoc/flowmvi/ui/search/SearchActivity.kt | 36 +++++++++++++++--- .../hoc/flowmvi/ui/search/SearchAdapter.kt | 37 +++++++++++++++++++ .../hoc/flowmvi/ui/search/SearchContract.kt | 24 +++++++++++- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 2 +- .../res/drawable/ic_baseline_person_24.xml | 10 +++++ .../src/main/res/layout/activity_search.xml | 21 ++++------- .../main/res/layout/item_recycler_user.xml | 1 - 11 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchAdapter.kt create mode 100644 feature-search/src/main/res/drawable/ic_baseline_person_24.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8a..7e7ee626 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index b47389d7..076b3e47 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,14 +3,16 @@ - + diff --git a/build.gradle.kts b/build.gradle.kts index b8aca77f..f953cfc1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ buildscript { 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") } } diff --git a/feature-search/build.gradle.kts b/feature-search/build.gradle.kts index 5628f5e3..501782d8 100644 --- a/feature-search/build.gradle.kts +++ b/feature-search/build.gradle.kts @@ -1,6 +1,7 @@ plugins { androidLib kotlinAndroid + id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin") } android { 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 38d97d76..96b1c897 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 @@ -2,19 +2,26 @@ 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.isVisible import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.hoc.flowmvi.core.SearchViewQueryTextEvent 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 kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -25,7 +32,6 @@ 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 @@ -35,6 +41,7 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { private val vm by viewModel() private val searchViewQueryTextEventChannel = Channel() + private val searchAdapter = SearchAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -45,12 +52,20 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { } private fun bindVM() { - vm.viewState.collectIn(this) { - Log.d("SearchActivity", it.toString()) + vm.viewState.collectIn(this) { viewState -> + searchAdapter.submitList(viewState.users) + binding.run { + errorGroup.isVisible = viewState.error !== null + errorMessageTextView.text = viewState.error?.message + + progressBar.isVisible = viewState.isLoading + } } - vm.singleEvent.collectIn(this) { - Log.d("SearchActivity", it.toString()) + vm.singleEvent.collectIn(this) { event -> + when (event) { + is SingleEvent.SearchFailure -> toast("Failed to search") + } } intents() @@ -67,6 +82,17 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { ) private fun setupViews() { + binding.run { + usersRecycler.run { + setHasFixedSize(true) + layoutManager = GridLayoutManager( + context, + if (context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 2 else 4, + ) + adapter = searchAdapter + addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) + } + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { 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..88f77073 --- /dev/null +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchAdapter.kt @@ -0,0 +1,37 @@ +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 coil.transform.CircleCropTransformation +import com.hoc.flowmvi.ui.search.databinding.ItemRecyclerUserBinding +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: ItemRecyclerUserBinding) : 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) + transformations(CircleCropTransformation()) + } + } + } + } +} 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 c82be646..e42f35e0 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 @@ -1,6 +1,26 @@ package com.hoc.flowmvi.ui.search import com.hoc.flowmvi.domain.entity.User +import dev.ahmedmourad.nocopy.annotations.NoCopy + +@NoCopy +internal data class UserItem private constructor( + val id: String, + val email: String, + val avatar: String, + val fullName: String, +) { + companion object { + 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 @@ -8,7 +28,7 @@ internal sealed interface ViewIntent { } internal data class ViewState( - val users: List, + val users: List, val isLoading: Boolean, val error: Throwable? ) { @@ -28,7 +48,7 @@ internal sealed interface PartialStateChange { sealed class Search : PartialStateChange { object Loading : Search() - data class Success(val users: List) : Search() + data class Success(val users: List) : Search() data class Failure(val error: Throwable) : Search() override fun reduce(state: ViewState) = when (this) { 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 ef24bac1..7127d7ef 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 @@ -62,7 +62,7 @@ internal class SearchVM( flow { emit(searchUsersUseCase(query)) } .map { @Suppress("USELESS_CAST") - PartialStateChange.Search.Success(it) as PartialStateChange.Search + PartialStateChange.Search.Success(it.map(UserItem::from)) as PartialStateChange.Search } .onStart { emit(PartialStateChange.Search.Loading) } .catch { emit(PartialStateChange.Search.Failure(it)) } 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/layout/activity_search.xml b/feature-search/src/main/res/layout/activity_search.xml index bb365f6f..ef5dd442 100644 --- a/feature-search/src/main/res/layout/activity_search.xml +++ b/feature-search/src/main/res/layout/activity_search.xml @@ -6,21 +6,14 @@ android:layout_height="match_parent" android:background="@android:color/white"> - - - - - + android:layout_height="match_parent" + android:clipToPadding="false" + android:scrollbars="vertical" + tools:itemCount="4" + tools:listitem="@layout/item_recycler_user" /> Date: Thu, 27 May 2021 09:21:43 +0700 Subject: [PATCH 19/26] wip --- .idea/compiler.xml | 2 +- .idea/gradle.xml | 2 +- .idea/misc.xml | 2 +- .idea/runConfigurations.xml | 10 ++++++++ .../hoc/flowmvi/data/UserRepositoryImpl.kt | 1 + .../hoc/flowmvi/ui/search/SearchActivity.kt | 10 +++++--- .../hoc/flowmvi/ui/search/SearchAdapter.kt | 6 ++--- .../hoc/flowmvi/ui/search/SearchContract.kt | 24 ++++++++++++++----- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 7 ++++-- .../src/main/res/layout/activity_search.xml | 24 +++++++++++++++---- ...user.xml => item_recycler_search_user.xml} | 0 11 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 .idea/runConfigurations.xml rename feature-search/src/main/res/layout/{item_recycler_user.xml => item_recycler_search_user.xml} (100%) diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 7e7ee626..fb7f4a8a 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index c22de178..686bec5b 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,7 +4,7 @@ - + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..797acea5 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file 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 1e33eef1..e86547aa 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -90,6 +90,7 @@ internal class UserRepositoryImpl constructor( } override suspend fun search(query: String) = withContext(dispatchers.io) { + delay(400) userApiService.search(query).map(responseToDomain) } 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 96b1c897..81a25bba 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 @@ -9,6 +9,7 @@ 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.DividerItemDecoration @@ -21,7 +22,6 @@ 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 kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -32,6 +32,7 @@ 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 @@ -54,7 +55,11 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { private fun bindVM() { vm.viewState.collectIn(this) { viewState -> searchAdapter.submitList(viewState.users) + binding.run { + textQuery.isInvisible = viewState.isLoading + textQuery.text = "Search results for '${viewState.query}'" + errorGroup.isVisible = viewState.error !== null errorMessageTextView.text = viewState.error?.message @@ -87,10 +92,9 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { setHasFixedSize(true) layoutManager = GridLayoutManager( context, - if (context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 2 else 4, + if (context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 3 else 4, ) adapter = searchAdapter - addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) } } } 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 index 88f77073..2e2dfe4b 100644 --- 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 @@ -5,8 +5,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.CircleCropTransformation -import com.hoc.flowmvi.ui.search.databinding.ItemRecyclerUserBinding +import com.hoc.flowmvi.ui.search.databinding.ItemRecyclerSearchUserBinding import com.hoc081098.viewbindingdelegate.inflateViewBinding internal class SearchAdapter : @@ -20,7 +19,7 @@ internal class SearchAdapter : override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position)) - class VH(private val binding: ItemRecyclerUserBinding) : RecyclerView.ViewHolder(binding.root) { + class VH(private val binding: ItemRecyclerSearchUserBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: UserItem) { binding.run { nameTextView.text = item.fullName @@ -29,7 +28,6 @@ internal class SearchAdapter : crossfade(200) placeholder(R.drawable.ic_baseline_person_24) error(R.drawable.ic_baseline_person_24) - transformations(CircleCropTransformation()) } } } 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 e42f35e0..3e470733 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 @@ -3,6 +3,7 @@ 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, @@ -10,7 +11,7 @@ internal data class UserItem private constructor( val avatar: String, val fullName: String, ) { - companion object { + companion object Factory { fun from(domain: User): UserItem { return UserItem( id = domain.id, @@ -30,7 +31,8 @@ internal sealed interface ViewIntent { internal data class ViewState( val users: List, val isLoading: Boolean, - val error: Throwable? + val error: Throwable?, + val query: String, ) { companion object Factory { fun initial(): ViewState { @@ -38,6 +40,7 @@ internal data class ViewState( users = emptyList(), isLoading = false, error = null, + query = "", ) } } @@ -48,13 +51,22 @@ internal sealed interface PartialStateChange { sealed class Search : PartialStateChange { object Loading : Search() - data class Success(val users: List) : Search() - data class Failure(val error: Throwable) : Search() + data class Success(val users: List, val query: String) : Search() + data class Failure(val error: Throwable, val query: String) : Search() override fun reduce(state: ViewState) = when (this) { - is Failure -> state.copy(isLoading = false, error = error) + is Failure -> state.copy( + isLoading = false, + error = error, + query = query, + ) Loading -> state.copy(isLoading = true, error = null) - is Success -> state.copy(isLoading = false, error = null, users = users) + is Success -> state.copy( + isLoading = false, + error = null, + users = users, + query = query, + ) } } } 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 7127d7ef..f421ab01 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 @@ -62,10 +62,13 @@ internal class SearchVM( flow { emit(searchUsersUseCase(query)) } .map { @Suppress("USELESS_CAST") - PartialStateChange.Search.Success(it.map(UserItem::from)) as PartialStateChange.Search + PartialStateChange.Search.Success( + it.map(UserItem::from), + query, + ) as PartialStateChange.Search } .onStart { emit(PartialStateChange.Search.Loading) } - .catch { emit(PartialStateChange.Search.Failure(it)) } + .catch { emit(PartialStateChange.Search.Failure(it, query)) } } return merge( diff --git a/feature-search/src/main/res/layout/activity_search.xml b/feature-search/src/main/res/layout/activity_search.xml index ef5dd442..56359fed 100644 --- a/feature-search/src/main/res/layout/activity_search.xml +++ b/feature-search/src/main/res/layout/activity_search.xml @@ -6,14 +6,30 @@ android:layout_height="match_parent" android:background="@android:color/white"> + + + tools:listitem="@layout/item_recycler_search_user" /> + app:layout_constraintTop_toTopOf="@id/usersRecycler" /> Date: Thu, 27 May 2021 09:37:51 +0700 Subject: [PATCH 20/26] wip --- .../hoc/flowmvi/ui/search/SearchActivity.kt | 8 +-- .../hoc/flowmvi/ui/search/SearchContract.kt | 37 +++++++------- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 51 +++++++++++-------- 3 files changed, 51 insertions(+), 45 deletions(-) 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 81a25bba..0794df6d 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 @@ -12,10 +12,9 @@ import androidx.appcompat.widget.SearchView import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView 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 @@ -57,7 +56,7 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { searchAdapter.submitList(viewState.users) binding.run { - textQuery.isInvisible = viewState.isLoading + textQuery.isInvisible = viewState.isLoading || viewState.query.isBlank() textQuery.text = "Search results for '${viewState.query}'" errorGroup.isVisible = viewState.error !== null @@ -83,7 +82,8 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) { searchViewQueryTextEventChannel .consumeAsFlow() .onEach { Log.d("SearchActivity", "Query $it") } - .map { ViewIntent.Search(it.query.toString()) } + .map { ViewIntent.Search(it.query.toString()) }, + binding.retryButton.clicks().map { ViewIntent.Retry }, ) private fun setupViews() { 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 3e470733..235fd122 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 @@ -47,27 +47,24 @@ internal data class ViewState( } internal sealed interface PartialStateChange { - fun reduce(state: ViewState): ViewState + object Loading : PartialStateChange + data class Success(val users: List, val query: String) : PartialStateChange + data class Failure(val error: Throwable, val query: String) : PartialStateChange - sealed class Search : PartialStateChange { - object Loading : Search() - data class Success(val users: List, val query: String) : Search() - data class Failure(val error: Throwable, val query: String) : Search() - - override fun reduce(state: ViewState) = when (this) { - is Failure -> state.copy( - isLoading = false, - error = error, - query = query, - ) - Loading -> state.copy(isLoading = true, error = null) - is Success -> state.copy( - isLoading = false, - error = null, - users = users, - query = query, - ) - } + 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, + ) } } 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 f421ab01..8c65abd1 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 @@ -3,6 +3,7 @@ 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.domain.usecase.SearchUsersUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -14,6 +15,7 @@ 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 @@ -53,35 +55,42 @@ internal class SearchVM( } private fun Flow.toPartialStateChangesFlow(): Flow { - val searchChange = filterIsInstance() - .debounce(Duration.milliseconds(400)) - .map { it.query } - .filter { it.isNotBlank() } - .distinctUntilChanged() - .flatMapLatest { query -> - flow { emit(searchUsersUseCase(query)) } - .map { - @Suppress("USELESS_CAST") - PartialStateChange.Search.Success( - it.map(UserItem::from), - query, - ) as PartialStateChange.Search - } - .onStart { emit(PartialStateChange.Search.Loading) } - .catch { emit(PartialStateChange.Search.Failure(it, query)) } - } + 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)) } + } return merge( - searchChange + filterIsInstance() + .debounce(Duration.milliseconds(400)) + .map { it.query } + .filter { it.isNotBlank() } + .distinctUntilChanged() + .flatMapLatest(executeSearch), + filterIsInstance() + .flatMapFirst { + viewState.value.let { vs -> + if (vs.error !== null) executeSearch(vs.query) + else emptyFlow() + } + }, ) } private fun Flow.sendSingleEvent(): Flow = onEach { change -> when (change) { - is PartialStateChange.Search.Failure -> _singleEvent.send(SingleEvent.SearchFailure(change.error)) - PartialStateChange.Search.Loading -> return@onEach - is PartialStateChange.Search.Success -> return@onEach + is PartialStateChange.Failure -> _singleEvent.send(SingleEvent.SearchFailure(change.error)) + PartialStateChange.Loading -> return@onEach + is PartialStateChange.Success -> return@onEach } } } From 2cefcdf0c602f87d3df7cff2b3ffc5ce647a1d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 10:18:18 +0700 Subject: [PATCH 21/26] wip --- .../flowmvi/core/ExampleInstrumentedTest.kt | 20 ----- ...owBinding+Exts+Utils.kt => FlowBinding.kt} | 81 +----------------- .../main/java/com/hoc/flowmvi/core/FlowExt.kt | 83 +++++++++++++++++++ .../com/hoc/flowmvi/core/ExampleUnitTest.kt | 13 --- 4 files changed, 84 insertions(+), 113 deletions(-) rename core/src/main/java/com/hoc/flowmvi/core/{FlowBinding+Exts+Utils.kt => FlowBinding.kt} (60%) create mode 100644 core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt 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.kt similarity index 60% rename from core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt rename to core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt index eeb029af..6f3f2f0a 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt @@ -10,28 +10,14 @@ import androidx.annotation.CheckResult import androidx.appcompat.widget.SearchView import androidx.core.widget.doOnTextChanged import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import kotlinx.coroutines.CancellationException +import kotlin.coroutines.EmptyCoroutineContext 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 internal fun checkMainThread() { check(Looper.myLooper() == Looper.getMainLooper()) { @@ -137,69 +123,4 @@ fun EditText.textChanges(): Flow { }.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() -} 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..28d1aa89 --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt @@ -0,0 +1,83 @@ +package com.hoc.flowmvi.core + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +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.launch + +@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..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/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt b/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt index 41038a9e..0d23001e 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,2 @@ 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) - } -} From b932ccd22b9eb1d6942700f5e102729b037f235a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 10:18:32 +0700 Subject: [PATCH 22/26] wip --- core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt | 2 +- core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt | 4 ++-- core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt index 6f3f2f0a..9b7903c2 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding.kt @@ -10,7 +10,6 @@ import androidx.annotation.CheckResult import androidx.appcompat.widget.SearchView import androidx.core.widget.doOnTextChanged import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose @@ -18,6 +17,7 @@ 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()) { diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt index 28d1aa89..5250b7c4 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt @@ -1,7 +1,5 @@ package com.hoc.flowmvi.core -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -16,6 +14,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference @ExperimentalCoroutinesApi fun Flow.flatMapFirst(transform: suspend (value: T) -> Flow): Flow = 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 0d23001e..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,2 +1 @@ package com.hoc.flowmvi.core - From 959ba4f0fe9ec66492155a5625d2d79437cadc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 10:30:17 +0700 Subject: [PATCH 23/26] wip --- .../main/java/com/hoc/flowmvi/core/FlowExt.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt index 5250b7c4..10d32f7a 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt @@ -16,6 +16,28 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.flow.take + +@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 = @@ -69,6 +91,14 @@ fun Flow.withLatestFrom(other: Flow, transform: suspend (A, B) - @ExperimentalCoroutinesApi suspend fun main() { + (1..100).asFlow() + .onEach { delay(50) } + .takeUntil(flow { delay(200); emit(Unit) }) + .collect { println(">>>>> $it") } + + println("Done") + return + (1..2000).asFlow() .onEach { delay(50) } .flatMapFirst { v -> From d99855e65fafcdf27a9cf83a63343779035a8414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 11:34:14 +0700 Subject: [PATCH 24/26] takeUntil --- .../main/java/com/hoc/flowmvi/core/FlowExt.kt | 3 +-- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt index 10d32f7a..8f9db366 100644 --- a/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt +++ b/core/src/main/java/com/hoc/flowmvi/core/FlowExt.kt @@ -13,10 +13,10 @@ 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 -import kotlinx.coroutines.flow.take @ExperimentalCoroutinesApi fun Flow.takeUntil(notifier: Flow): Flow = channelFlow { @@ -97,7 +97,6 @@ suspend fun main() { .collect { println(">>>>> $it") } println("Done") - return (1..2000).asFlow() .onEach { delay(50) } 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 8c65abd1..7e0a76ea 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 @@ -4,6 +4,7 @@ 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 @@ -26,6 +27,7 @@ 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 @@ -68,17 +70,22 @@ internal class SearchVM( .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( - filterIsInstance() - .debounce(Duration.milliseconds(400)) - .map { it.query } - .filter { it.isNotBlank() } - .distinctUntilChanged() - .flatMapLatest(executeSearch), + queryFlow.flatMapLatest(executeSearch), filterIsInstance() .flatMapFirst { viewState.value.let { vs -> - if (vs.error !== null) executeSearch(vs.query) + if (vs.error !== null) executeSearch(vs.query).takeUntil(queryFlow) else emptyFlow() } }, From e6abade9290dc28a2898b2d2c16886a88435feb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 11:38:02 +0700 Subject: [PATCH 25/26] add leakCanary --- app/build.gradle.kts | 2 ++ buildSrc/src/main/kotlin/deps.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26402f69..0ab64c98 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { 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/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index 87621dbc..2abd8447 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -43,6 +43,7 @@ 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 { From 16ab24b638fa8ef8901e552598efb2857fc7c329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petrus=20Nguy=E1=BB=85n=20Th=C3=A1i=20H=E1=BB=8Dc?= Date: Thu, 27 May 2021 11:55:03 +0700 Subject: [PATCH 26/26] bum deps --- .idea/kotlinc.xml | 4 ++-- app/src/main/java/com/hoc/flowmvi/App.kt | 3 +-- buildSrc/src/main/kotlin/deps.kt | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 3097f319..57f05c9d 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,7 +1,7 @@ - \ No newline at end of file diff --git a/app/src/main/java/com/hoc/flowmvi/App.kt b/app/src/main/java/com/hoc/flowmvi/App.kt index f4b06c46..6c95e0b2 100644 --- a/app/src/main/java/com/hoc/flowmvi/App.kt +++ b/app/src/main/java/com/hoc/flowmvi/App.kt @@ -26,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, diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index 2abd8447..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" @@ -54,7 +54,7 @@ object deps { } 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"