Quick search across genres in filter

This commit is contained in:
Koitharu
2022-03-08 17:26:45 +02:00
parent 179b08b96a
commit 148986b454
11 changed files with 222 additions and 71 deletions

View File

@@ -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<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -17,6 +22,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : 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<B : ViewBinding> : 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<View>(materialR.id.design_bottom_sheet)
rootView?.updateLayoutParams {
height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
}
b.isDraggable = !isLocked
}
}

View File

@@ -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<SheetFilterBinding>() {
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, DialogInterface.OnKeyListener {
private val viewModel by sharedViewModel<FilterViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) }
@@ -33,6 +34,12 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>() {
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<SheetFilterBinding>() {
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<SheetFilterBinding>() {
viewModel.result.observe(viewLifecycleOwner) {
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it))
}
initOptionsMenu()
}
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
setExpanded(isExpanded = true, isLocked = true)
return true
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
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 onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
} else {
binding.toolbar.navigationIcon = null
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performSearch(newText?.trim().orEmpty())
return true
}
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 {

View File

@@ -9,4 +9,23 @@ import org.koitharu.kotatsu.core.model.SortOrder
class FilterState(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable
) : 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
}
}

View File

@@ -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<MangaTag>()
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,10 +119,48 @@ 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<FilterItem>()
val mappedTags = TreeSet<FilterItem.Tag>(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<MangaTag>? {
val shouldRetryOnError = availableTagsDeferred.isCompleted
val result = availableTagsDeferred.await()

View File

@@ -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<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@@ -31,6 +30,7 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), 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<SheetChaptersBinding>(), 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()

View File

@@ -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<SheetPagesBinding>(),
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<SheetPagesBinding>(),
}
}
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<SheetPagesBinding>(),
}
}
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"

View File

@@ -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

View File

@@ -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) {
}
}

View File

@@ -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 <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>
}
}
fun <T> LiveData<T>.observeDistinct(owner: LifecycleOwner, observer: Observer<T>) {
var previousValue: T? = null
this.observe(owner) {
if (it != previousValue) {
observer.onChanged(it)
previousValue = it
}
}
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null
this.observe(owner) {

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"
android:title="@string/find_genre"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
</menu>

View File

@@ -265,4 +265,5 @@
<string name="disabled">Disabled</string>
<string name="filter_load_error">Unable to load genres list</string>
<string name="reset_filter">Reset filter</string>
<string name="find_genre">Find genre</string>
</resources>