From 148986b454e28bb307ddf74b3518962c88bdce56 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 8 Mar 2022 17:26:45 +0200 Subject: [PATCH] Quick search across genres in filter --- .../kotatsu/base/ui/BaseBottomSheet.kt | 21 ++++++ .../list/ui/filter/FilterBottomSheet.kt | 69 ++++++++++++++----- .../kotatsu/list/ui/filter/FilterState.kt | 21 +++++- .../kotatsu/list/ui/filter/FilterViewModel.kt | 66 ++++++++++++++++-- .../kotatsu/reader/ui/ChaptersBottomSheet.kt | 22 +----- .../ui/thumbnails/PagesThumbnailsSheet.kt | 43 +++++------- .../remotelist/ui/RemoteListViewModel.kt | 3 + .../utils/BottomSheetToolbarController.kt | 23 +++++++ .../koitharu/kotatsu/utils/ext/LiveDataExt.kt | 11 ++- app/src/main/res/menu/opt_filter.xml | 13 ++++ app/src/main/res/values/strings.xml | 1 + 11 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt create mode 100644 app/src/main/res/menu/opt_filter.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt index d5d2865c0..d65e031fa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt @@ -5,10 +5,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams import androidx.appcompat.app.AppCompatDialog +import androidx.core.view.updateLayoutParams import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.koitharu.kotatsu.R +import com.google.android.material.R as materialR abstract class BaseBottomSheet : BottomSheetDialogFragment() { @@ -17,6 +22,9 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() { protected val binding: B get() = checkNotNull(viewBinding) + protected val behavior: BottomSheetBehavior<*>? + get() = (dialog as? BottomSheetDialog)?.behavior + final override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -39,4 +47,17 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() { } protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B + + protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { + val b = behavior ?: return + if (isExpanded) { + b.state = BottomSheetBehavior.STATE_EXPANDED + } + b.isFitToContents = !isExpanded + val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) + rootView?.updateLayoutParams { + height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT + } + b.isDraggable = !isLocked + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt index 6f7363b39..186ae12ed 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -1,13 +1,12 @@ package org.koitharu.kotatsu.list.ui.filter +import android.app.Dialog +import android.content.DialogInterface import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.view.* +import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.core.parameter.parametersOf @@ -15,9 +14,11 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.SheetFilterBinding +import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.ext.withArgs -class FilterBottomSheet : BaseBottomSheet() { +class FilterBottomSheet : BaseBottomSheet(), MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { private val viewModel by sharedViewModel( owner = { from(requireParentFragment(), requireParentFragment()) } @@ -33,6 +34,12 @@ class FilterBottomSheet : BaseBottomSheet() { viewModel.updateState(state) } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).also { + it.setOnKeyListener(this) + } + } + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) } @@ -40,6 +47,7 @@ class FilterBottomSheet : BaseBottomSheet() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.setNavigationOnClickListener { dismiss() } + behavior?.addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) if (!resources.getBoolean(R.bool.is_tablet)) { binding.toolbar.navigationIcon = null } @@ -49,24 +57,49 @@ class FilterBottomSheet : BaseBottomSheet() { viewModel.result.observe(viewLifecycleOwner) { parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it)) } + initOptionsMenu() + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + setExpanded(isExpanded = true, isLocked = true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + val searchView = (item.actionView as? SearchView) ?: return false + searchView.setQuery("", false) + searchView.post { setExpanded(isExpanded = false, isLocked = false) } + return true } - override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also { - val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also - behavior.addBottomSheetCallback( - object : BottomSheetBehavior.BottomSheetCallback() { + override fun onQueryTextSubmit(query: String?): Boolean = false - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.performSearch(newText?.trim().orEmpty()) + return true + } - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - binding.toolbar.setNavigationIcon(R.drawable.ic_cross) - } else { - binding.toolbar.navigationIcon = null - } + override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false + if (menuItem.isActionViewExpanded) { + if (event?.action == KeyEvent.ACTION_UP) { + menuItem.collapseActionView() } + return true } - ) + } + return false + } + + private fun initOptionsMenu() { + binding.toolbar.inflateMenu(R.menu.opt_filter) + val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt index 1c1c8a9cf..fdb42a261 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt @@ -9,4 +9,23 @@ import org.koitharu.kotatsu.core.model.SortOrder class FilterState( val sortOrder: SortOrder?, val tags: Set, -) : Parcelable \ No newline at end of file +) : Parcelable { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FilterState + + if (sortOrder != other.sortOrder) return false + if (tags != other.tags) return false + + return true + } + + override fun hashCode(): Int { + var result = sortOrder?.hashCode() ?: 0 + result = 31 * result + tags.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt index 0782b27db..97d41e966 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.list.ui.filter import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.* @@ -24,6 +25,7 @@ class FilterViewModel( private var job: Job? = null private var selectedSortOrder: SortOrder? = repository.sortOrders.firstOrNull() private val selectedTags = HashSet() + private var searchQuery: String = "" private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) { dataRepository.findTags(repository.source) } @@ -31,7 +33,7 @@ class FilterViewModel( override fun onSortItemClick(item: FilterItem.Sort) { selectedSortOrder = item.order - updateFilters() + updateFilters(updateResults = true) } override fun onTagItemClick(item: FilterItem.Tag) { @@ -41,7 +43,7 @@ class FilterViewModel( selectedTags.add(item.tag) } if (isModified) { - updateFilters() + updateFilters(updateResults = true) } } @@ -53,15 +55,27 @@ class FilterViewModel( if (job == null) { showFilter() } else { - updateFilters() + updateFilters(updateResults = false) + } + } + + fun performSearch(query: String) { + if (searchQuery != query) { + searchQuery = query + updateFilters(updateResults = false) } } @AnyThread - private fun updateFilters() { + private fun updateFilters(updateResults: Boolean) { val previousJob = job + val query = searchQuery job = launchJob(Dispatchers.Default) { previousJob?.cancelAndJoin() + if (query.isNotEmpty()) { + showFilteredTags(query) + return@launchJob + } val tags = tryLoadTags() val localTags = localTagsDeferred.await() val sortOrders = repository.sortOrders @@ -84,7 +98,9 @@ class FilterViewModel( ensureActive() filter.postValue(list) } - result.postValue(FilterState(selectedSortOrder, selectedTags)) + if (updateResults) { + result.postValue(FilterState(selectedSortOrder, selectedTags)) + } } private fun showFilter() { @@ -103,8 +119,46 @@ class FilterViewModel( } list.add(FilterItem.Loading) filter.postValue(list) - updateFilters() + updateFilters(updateResults = false) + } + } + + @WorkerThread + private suspend fun showFilteredTags(query: String) { + val tags = tryLoadTags() + val localTags = localTagsDeferred.await() + val list = ArrayList() + val mappedTags = TreeSet(compareBy({ !it.isChecked }, { it.tag.title })) + localTags.mapNotNullTo(mappedTags) { + if (it.title.contains(query, ignoreCase = true)) { + FilterItem.Tag(it, isChecked = it in selectedTags) + } else { + null + } + } + tags?.mapNotNullTo(mappedTags) { + if (it.title.contains(query, ignoreCase = true)) { + FilterItem.Tag(it, isChecked = it in selectedTags) + } else { + null + } + } + selectedTags.mapNotNullTo(mappedTags) { + if (it.title.contains(query, ignoreCase = true)) { + FilterItem.Tag(it, isChecked = true) + } else { + null + } + } + list.addAll(mappedTags) + if (tags == null) { + list.add(FilterItem.Error(R.string.filter_load_error)) + } + if (list.isEmpty()) { + list.add(FilterItem.Error(R.string.nothing_found)) } + currentCoroutineContext().ensureActive() + filter.postValue(list) } private suspend fun tryLoadTags(): Set? { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt index 1ec6ffa7a..3b079255a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt @@ -7,8 +7,6 @@ import android.view.ViewGroup import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.divider.MaterialDividerItemDecoration import org.koin.android.ext.android.get import org.koitharu.kotatsu.R @@ -20,6 +18,7 @@ import org.koitharu.kotatsu.databinding.SheetChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem +import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.ext.withArgs class ChaptersBottomSheet : BaseBottomSheet(), OnListItemClickListener { @@ -31,6 +30,7 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.setNavigationOnClickListener { dismiss() } + behavior?.addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) if (!resources.getBoolean(R.bool.is_tablet)) { binding.toolbar.navigationIcon = null } @@ -65,24 +65,6 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC } } - override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also { - val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also - behavior.addBottomSheetCallback( - object : BottomSheetBehavior.BottomSheetCallback() { - - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - binding.toolbar.setNavigationIcon(R.drawable.ic_cross) - } else { - binding.toolbar.navigationIcon = null - } - } - } - ) - } - override fun onItemClick(item: ChapterListItem, view: View) { ((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let { dismiss() diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt index 0a2f4f2cb..314b281dc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt @@ -4,11 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import org.koin.android.ext.android.get import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet @@ -19,6 +18,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.SheetPagesBinding import org.koitharu.kotatsu.list.ui.MangaListSpanResolver import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.withArgs @@ -59,6 +59,7 @@ class PagesThumbnailsSheet : BaseBottomSheet(), binding.toolbar.title = title binding.toolbar.setNavigationOnClickListener { dismiss() } binding.toolbar.subtitle = null + behavior?.addBottomSheetCallback(ToolbarController(binding.toolbar)) if (!resources.getBoolean(R.bool.is_tablet)) { binding.toolbar.navigationIcon = null @@ -93,29 +94,6 @@ class PagesThumbnailsSheet : BaseBottomSheet(), } } - override fun onCreateDialog(savedInstanceState: Bundle?) = - super.onCreateDialog(savedInstanceState).also { - val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also - behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - binding.toolbar.setNavigationIcon(R.drawable.ic_cross) - binding.toolbar.subtitle = - resources.getQuantityString(R.plurals.pages, - thumbnails.size, - thumbnails.size) - } else { - binding.toolbar.navigationIcon = null - binding.toolbar.subtitle = null - } - } - }) - - } - override fun onItemClick(item: MangaPage, view: View) { ((parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener))?.run { @@ -124,6 +102,21 @@ class PagesThumbnailsSheet : BaseBottomSheet(), } } + private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) { + override fun onStateChanged(bottomSheet: View, newState: Int) { + super.onStateChanged(bottomSheet, newState) + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + toolbar.subtitle = resources.getQuantityString( + R.plurals.pages, + thumbnails.size, + thumbnails.size + ) + } else { + toolbar.subtitle = null + } + } + } + companion object { private const val ARG_PAGES = "pages" diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index a41ed190f..3d1b2b155 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -84,6 +84,9 @@ class RemoteListViewModel( } fun applyFilter(newFilter: FilterState) { + if (filter == newFilter) { + return + } filter = newFilter headerModel.value = ListHeader(repository.title, 0, newFilter.sortOrder) mangaList.value = null diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt new file mode 100644 index 000000000..9a703ffd7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.utils + +import android.view.View +import androidx.appcompat.widget.Toolbar +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.R as materialR + +open class BottomSheetToolbarController( + protected val toolbar: Toolbar, +) : BottomSheetBehavior.BottomSheetCallback() { + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material) + } else { + toolbar.navigationIcon = null + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index 7ca6b52fe..97b5732a3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.liveData import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import org.koitharu.kotatsu.utils.BufferedObserver import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -18,6 +17,16 @@ fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer } } +fun LiveData.observeDistinct(owner: LifecycleOwner, observer: Observer) { + var previousValue: T? = null + this.observe(owner) { + if (it != previousValue) { + observer.onChanged(it) + previousValue = it + } + } +} + fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { var previous: T? = null this.observe(owner) { diff --git a/app/src/main/res/menu/opt_filter.xml b/app/src/main/res/menu/opt_filter.xml new file mode 100644 index 000000000..7a62b5946 --- /dev/null +++ b/app/src/main/res/menu/opt_filter.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 992162039..0971d4c4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,4 +265,5 @@ Disabled Unable to load genres list Reset filter + Find genre \ No newline at end of file