Fix filter
parent
d5d19c37d8
commit
755f1e5747
@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class FitHeightGridLayoutManager : GridLayoutManager {
|
||||
|
||||
constructor(context: Context?, spanCount: Int) : super(context, spanCount)
|
||||
|
||||
constructor(
|
||||
context: Context?,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
constructor(
|
||||
context: Context?,
|
||||
spanCount: Int,
|
||||
orientation: Int,
|
||||
reverseLayout: Boolean,
|
||||
) : super(context, spanCount, orientation, reverseLayout)
|
||||
|
||||
|
||||
override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||
val parentBottom = height - paddingBottom
|
||||
val offset = parentBottom - bottom
|
||||
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||
} else {
|
||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.LayoutParams
|
||||
|
||||
class FitHeightLinearLayoutManager : LinearLayoutManager {
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(
|
||||
context: Context,
|
||||
@RecyclerView.Orientation orientation: Int,
|
||||
reverseLayout: Boolean,
|
||||
) : super(context, orientation, reverseLayout)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
@AttrRes defStyleAttr: Int,
|
||||
@StyleRes defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||
val parentBottom = height - paddingBottom
|
||||
val offset = parentBottom - bottom
|
||||
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||
} else {
|
||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import java.util.*
|
||||
|
||||
class FilterCoordinator(
|
||||
private val repository: RemoteMangaRepository,
|
||||
dataRepository: MangaDataRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : OnFilterChangedListener {
|
||||
|
||||
private val currentState = MutableStateFlow(FilterState(repository.sortOrders.firstOrNull(), emptySet()))
|
||||
private var searchQuery = MutableStateFlow("")
|
||||
private val localTagsDeferred = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||
dataRepository.findTags(repository.source)
|
||||
}
|
||||
private var availableTagsDeferred = loadTagsAsync()
|
||||
|
||||
val items = getItemsFlow()
|
||||
.asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
observeState()
|
||||
}
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(item.order, oldValue.tags)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
currentState.update { oldValue ->
|
||||
val newTags = if (item.isChecked) {
|
||||
oldValue.tags - item.tag
|
||||
} else {
|
||||
oldValue.tags + item.tag
|
||||
}
|
||||
FilterState(oldValue.sortOrder, newTags)
|
||||
}
|
||||
}
|
||||
|
||||
fun observeState() = currentState.asStateFlow()
|
||||
|
||||
fun removeTag(tag: MangaTag) {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(oldValue.sortOrder, oldValue.tags - tag)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(oldValue.sortOrder, emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot() = currentState.value
|
||||
|
||||
fun performSearch(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
private fun getItemsFlow() = combine(
|
||||
getTagsAsFlow(),
|
||||
currentState,
|
||||
searchQuery,
|
||||
) { tags, state, query ->
|
||||
buildFilterList(tags, state, query)
|
||||
}
|
||||
|
||||
private fun getTagsAsFlow() = flow {
|
||||
val localTags = localTagsDeferred.await()
|
||||
emit(TagsWrapper(localTags, isLoading = true, isError = false))
|
||||
val remoteTags = tryLoadTags()
|
||||
if (remoteTags == null) {
|
||||
emit(TagsWrapper(localTags, isLoading = false, isError = true))
|
||||
} else {
|
||||
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false))
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun buildFilterList(
|
||||
allTags: TagsWrapper,
|
||||
state: FilterState,
|
||||
query: String,
|
||||
): List<FilterItem> {
|
||||
val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
|
||||
val tags = mergeTags(state.tags, allTags.tags).sortedBy { it.title }
|
||||
val list = ArrayList<FilterItem>(tags.size + sortOrders.size + 3)
|
||||
if (query.isEmpty()) {
|
||||
if (sortOrders.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.sort_order, 0))
|
||||
sortOrders.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == state.sortOrder)
|
||||
}
|
||||
}
|
||||
if(allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres, state.tags.size))
|
||||
tags.mapTo(list) {
|
||||
FilterItem.Tag(it, isChecked = it in state.tags)
|
||||
}
|
||||
}
|
||||
if (allTags.isError) {
|
||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||
} else if (allTags.isLoading) {
|
||||
list.add(FilterItem.Loading)
|
||||
}
|
||||
} else {
|
||||
tags.mapNotNullTo(list) {
|
||||
if (it.title.contains(query, ignoreCase = true)) {
|
||||
FilterItem.Tag(it, isChecked = it in state.tags)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (list.isEmpty()) {
|
||||
list.add(FilterItem.Error(R.string.nothing_found))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun tryLoadTags(): Set<MangaTag>? {
|
||||
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
||||
val result = availableTagsDeferred.await()
|
||||
if (result == null && shouldRetryOnError) {
|
||||
availableTagsDeferred = loadTagsAsync()
|
||||
return availableTagsDeferred.await()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||
runCatching {
|
||||
repository.getTags()
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) {
|
||||
error.printStackTrace()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
||||
val result = TreeSet(TagTitleComparator())
|
||||
result.addAll(secondary)
|
||||
result.addAll(primary)
|
||||
return result
|
||||
}
|
||||
|
||||
private class TagsWrapper(
|
||||
val tags: Set<MangaTag>,
|
||||
val isLoading: Boolean,
|
||||
val isError: Boolean,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as TagsWrapper
|
||||
|
||||
if (tags != other.tags) return false
|
||||
if (isLoading != other.isLoading) return false
|
||||
if (isError != other.isError) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = tags.hashCode()
|
||||
result = 31 * result + isLoading.hashCode()
|
||||
result = 31 * result + isError.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class TagTitleComparator : Comparator<MangaTag> {
|
||||
|
||||
override fun compare(o1: MangaTag, o2: MangaTag) = compareValues(
|
||||
o1.title.lowercase(),
|
||||
o2.title.lowercase(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
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.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
||||
import java.util.*
|
||||
|
||||
class FilterViewModel(
|
||||
private val repository: RemoteMangaRepository,
|
||||
dataRepository: MangaDataRepository,
|
||||
) : BaseViewModel(), OnFilterChangedListener {
|
||||
|
||||
val filter = MutableLiveData<List<FilterItem>>()
|
||||
val result = MutableLiveData<FilterState>()
|
||||
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)
|
||||
}
|
||||
private var availableTagsDeferred = loadTagsAsync()
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
selectedSortOrder = item.order
|
||||
updateFilters(updateResults = true)
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
val isModified = if (item.isChecked) {
|
||||
selectedTags.remove(item.tag)
|
||||
} else {
|
||||
selectedTags.add(item.tag)
|
||||
}
|
||||
if (isModified) {
|
||||
updateFilters(updateResults = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateState(state: FilterState?) {
|
||||
if (state != null) {
|
||||
selectedSortOrder = state.sortOrder
|
||||
selectedTags.replaceWith(state.tags)
|
||||
}
|
||||
if (job == null) {
|
||||
showFilter()
|
||||
} else {
|
||||
updateFilters(updateResults = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun performSearch(query: String) {
|
||||
if (searchQuery != query) {
|
||||
searchQuery = query
|
||||
updateFilters(updateResults = false)
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
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
|
||||
val list = ArrayList<FilterItem>(sortOrders.size + (tags?.size ?: 1) + 2)
|
||||
list.add(FilterItem.Header(R.string.sort_order))
|
||||
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||
}
|
||||
if (tags == null || tags.isNotEmpty() || selectedTags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres))
|
||||
val mappedTags = TreeSet<FilterItem.Tag>(compareBy({ !it.isChecked }, { it.tag.title }))
|
||||
localTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||
tags?.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||
selectedTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = true) }
|
||||
list.addAll(mappedTags)
|
||||
if (tags == null) {
|
||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||
}
|
||||
}
|
||||
ensureActive()
|
||||
filter.postValue(list)
|
||||
}
|
||||
if (updateResults) {
|
||||
result.postValue(FilterState(selectedSortOrder, selectedTags))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFilter() {
|
||||
job = launchJob(Dispatchers.Default) {
|
||||
val sortOrders = repository.sortOrders
|
||||
val list = ArrayList<FilterItem>(sortOrders.size + selectedTags.size + 3)
|
||||
list.add(FilterItem.Header(R.string.sort_order))
|
||||
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||
}
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres))
|
||||
selectedTags.sortedBy { it.title }.mapTo(list) {
|
||||
FilterItem.Tag(it, isChecked = it in selectedTags)
|
||||
}
|
||||
}
|
||||
list.add(FilterItem.Loading)
|
||||
filter.postValue(list)
|
||||
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()
|
||||
if (result == null && shouldRetryOnError) {
|
||||
availableTagsDeferred = loadTagsAsync()
|
||||
return availableTagsDeferred.await()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadTagsAsync() = viewModelScope.async(Dispatchers.Default) {
|
||||
runCatching {
|
||||
repository.getTags()
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) {
|
||||
error.printStackTrace()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical|start"
|
||||
android:minHeight="@dimen/header_height"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="@drawable/badge"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:textColor="?attr/colorOnTertiary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:text="54"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
Loading…
Reference in New Issue