Fix filter

pull/126/head
Koitharu 4 years ago
parent d5d19c37d8
commit 755f1e5747
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

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

@ -8,13 +8,14 @@ import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
@ -214,19 +215,19 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
removeOnLayoutChangeListener(spanResolver) removeOnLayoutChangeListener(spanResolver)
when (mode) { when (mode) {
ListMode.LIST -> { ListMode.LIST -> {
layoutManager = LinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing)) addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing) updatePadding(left = spacing, right = spacing)
} }
ListMode.DETAILED_LIST -> { ListMode.DETAILED_LIST -> {
layoutManager = LinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
updatePadding(left = spacing, right = spacing) updatePadding(left = spacing, right = spacing)
addItemDecoration(SpacingItemDecoration(spacing)) addItemDecoration(SpacingItemDecoration(spacing))
} }
ListMode.GRID -> { ListMode.GRID -> {
layoutManager = GridLayoutManager(context, spanResolver.spanCount).also { layoutManager = FitHeightGridLayoutManager(context, spanResolver.spanCount).also {
it.spanSizeLookup = spanSizeLookup it.spanSizeLookup = spanSizeLookup
} }
val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing)

@ -5,10 +5,7 @@ import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
class MangaListAdapter( class MangaListAdapter(
@ -60,6 +57,16 @@ class MangaListAdapter(
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem) return Intrinsics.areEqual(oldItem, newItem)
} }
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when (newItem) {
is MangaListModel,
is MangaGridModel,
is MangaListDetailedModel,
is CurrentFilterModel -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}
} }
companion object { companion object {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -45,7 +46,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
) { ) {
bind { bind {
binding.root.setText(item.titleResId) binding.textViewTitle.setText(item.titleResId)
binding.badge.isVisible = if (item.counter == 0) {
false
} else {
binding.badge.text = item.counter.toString()
true
}
} }
} }

@ -5,34 +5,21 @@ import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.withArgs
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener, class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { SearchView.OnQueryTextListener, DialogInterface.OnKeyListener {
private val viewModel by sharedViewModel<FilterViewModel>( private val viewModel by sharedViewModel<RemoteListViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) } owner = { from(requireParentFragment(), requireParentFragment()) }
) { )
parametersOf(
requireArguments().getParcelable<MangaSource>(ARG_SOURCE),
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val state = requireArguments().getParcelable<FilterState>(ARG_STATE)
viewModel.updateState(state)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also { return super.onCreateDialog(savedInstanceState).also {
@ -53,10 +40,7 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
} }
val adapter = FilterAdapter(viewModel) val adapter = FilterAdapter(viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.filter.observe(viewLifecycleOwner, adapter::setItems) viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
viewModel.result.observe(viewLifecycleOwner) {
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it))
}
initOptionsMenu() initOptionsMenu()
} }
@ -75,7 +59,7 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performSearch(newText?.trim().orEmpty()) viewModel.filterSearch(newText?.trim().orEmpty())
return true return true
} }
@ -104,19 +88,8 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
companion object { companion object {
const val REQUEST_KEY = "filter"
const val ARG_STATE = "state"
private const val TAG = "FilterBottomSheet" private const val TAG = "FilterBottomSheet"
private const val ARG_SOURCE = "source"
fun show( fun show(fm: FragmentManager) = FilterBottomSheet().show(fm, TAG)
fm: FragmentManager,
source: MangaSource,
state: FilterState,
) = FilterBottomSheet().withArgs(2) {
putParcelable(ARG_SOURCE, source)
putParcelable(ARG_STATE, state)
}.show(fm, TAG)
} }
} }

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

@ -27,7 +27,9 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when { return when {
oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.counter == newItem.counter
}
oldItem is FilterItem.Error && newItem is FilterItem.Error -> true oldItem is FilterItem.Error && newItem is FilterItem.Error -> true
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked == newItem.isChecked oldItem.isChecked == newItem.isChecked
@ -40,15 +42,18 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
} }
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? { override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
val isCheckedChanged = when { val hasPayload = when {
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked != newItem.isChecked oldItem.isChecked != newItem.isChecked
} }
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected != newItem.isSelected oldItem.isSelected != newItem.isSelected
} }
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.counter != newItem.counter
}
else -> false else -> false
} }
return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem) return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
} }
} }

@ -8,6 +8,7 @@ sealed interface FilterItem {
class Header( class Header(
@StringRes val titleResId: Int, @StringRes val titleResId: Int,
val counter: Int,
) : FilterItem ) : FilterItem
class Sort( class Sort(

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

@ -6,7 +6,6 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.list.ui.filter.FilterViewModel
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
val remoteListModule val remoteListModule
@ -16,12 +15,6 @@ val remoteListModule
RemoteListViewModel( RemoteListViewModel(
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository, repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
settings = get(), settings = get(),
)
}
viewModel { params ->
FilterViewModel(
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
dataRepository = get(), dataRepository = get(),
) )
} }

@ -1,23 +1,19 @@
package org.koitharu.kotatsu.remotelist.ui package org.koitharu.kotatsu.remotelist.ui
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.fragment.app.FragmentResultListener
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
import org.koitharu.kotatsu.utils.ext.parcelableArgument import org.koitharu.kotatsu.utils.ext.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment(), FragmentResultListener { class RemoteListFragment : MangaListFragment() {
override val viewModel by viewModel<RemoteListViewModel> { override val viewModel by viewModel<RemoteListViewModel> {
parametersOf(source) parametersOf(source)
@ -25,11 +21,6 @@ class RemoteListFragment : MangaListFragment(), FragmentResultListener {
private val source by parcelableArgument<MangaSource>(ARG_SOURCE) private val source by parcelableArgument<MangaSource>(ARG_SOURCE)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
childFragmentManager.setFragmentResultListener(FilterBottomSheet.REQUEST_KEY, viewLifecycleOwner, this)
}
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadNextPage() viewModel.loadNextPage()
} }
@ -63,19 +54,11 @@ class RemoteListFragment : MangaListFragment(), FragmentResultListener {
} }
override fun onFilterClick() { override fun onFilterClick() {
FilterBottomSheet.show(childFragmentManager, source, viewModel.filter) FilterBottomSheet.show(childFragmentManager)
} }
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
viewModel.applyFilter(FilterState(viewModel.filter.sortOrder, emptySet())) viewModel.resetFilter()
}
override fun onFragmentResult(requestKey: String, result: Bundle) {
when (requestKey) {
FilterBottomSheet.REQUEST_KEY -> viewModel.applyFilter(
result.getParcelable(FilterBottomSheet.ARG_STATE) ?: return
)
}
} }
companion object { companion object {

@ -1,100 +1,116 @@
package org.koitharu.kotatsu.remotelist.ui package org.koitharu.kotatsu.remotelist.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator
import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.filter.FilterState import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
private const val FILTER_MIN_INTERVAL = 750L
class RemoteListViewModel( class RemoteListViewModel(
private val repository: RemoteMangaRepository, private val repository: RemoteMangaRepository,
settings: AppSettings settings: AppSettings,
) : MangaListViewModel(settings) { dataRepository: MangaDataRepository,
) : MangaListViewModel(settings), OnFilterChangedListener {
var filter = FilterState(repository.sortOrders.firstOrNull(), emptySet()) private val filter = FilterCoordinator(repository, dataRepository, viewModelScope)
private set
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null private var loadingJob: Job? = null
private val headerModel = MutableStateFlow(
ListHeader(repository.title, 0, filter.sortOrder) val filterItems: LiveData<List<FilterItem>>
) get() = filter.items
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), createListModeFlow(),
headerModel, filter.observeState(),
listError, listError,
hasNextPage hasNextPage,
) { list, mode, header, error, hasNext -> ) { list, mode, filterState, error, hasNext ->
when { buildList(list?.size?.plus(3) ?: 3) {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) add(ListHeader(repository.title, 0, filterState.sortOrder))
list == null -> listOf(LoadingState) createFilterModel(filterState)?.let { add(it) }
list.isEmpty() -> createEmptyState() when {
else -> { list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true))
val result = ArrayList<ListModel>(list.size + 3) list == null -> add(LoadingState)
result += header list.isEmpty() -> add(createEmptyState(filterState))
createFilterModel()?.let { result.add(it) } else -> {
list.toUi(result, mode) list.toUi(this, mode)
when { when {
error != null -> result += error.toErrorFooter() error != null -> add(error.toErrorFooter())
hasNext -> result += LoadingFooter hasNext -> add(LoadingFooter)
}
} }
result
} }
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(ListHeader(repository.title, 0, null), LoadingState),
)
init { init {
loadList(false) filter.observeState()
.debounce(FILTER_MIN_INTERVAL)
.onEach { filterState ->
loadingJob?.cancelAndJoin()
mangaList.value = null
hasNextPage.value = false
loadList(filterState, false)
}.catch { error ->
listError.value = error
}.launchIn(viewModelScope)
} }
override fun onRefresh() { override fun onRefresh() {
loadList(append = false) loadList(filter.snapshot(), append = false)
} }
override fun onRetry() { override fun onRetry() {
loadList(append = !mangaList.value.isNullOrEmpty()) loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty())
} }
override fun onRemoveFilterTag(tag: MangaTag) { override fun onRemoveFilterTag(tag: MangaTag) {
val tags = filter.tags filter.removeTag(tag)
if (tag !in tags) { }
return
} override fun onSortItemClick(item: FilterItem.Sort) {
applyFilter(FilterState(filter.sortOrder, tags - tag)) filter.onSortItemClick(item)
}
override fun onTagItemClick(item: FilterItem.Tag) {
filter.onTagItemClick(item)
} }
fun loadNextPage() { fun loadNextPage() {
if (hasNextPage.value && listError.value == null) { if (hasNextPage.value && listError.value == null) {
loadList(append = true) loadList(filter.snapshot(), append = true)
} }
} }
fun applyFilter(newFilter: FilterState) { fun filterSearch(query: String) = filter.performSearch(query)
if (filter == newFilter) {
return fun resetFilter() = filter.reset()
}
filter = newFilter
headerModel.value = ListHeader(repository.title, 0, newFilter.sortOrder)
mangaList.value = null
hasNextPage.value = false
loadList(false)
}
private fun loadList(append: Boolean) { private fun loadList(filterState: FilterState, append: Boolean) {
if (loadingJob?.isActive == true) { if (loadingJob?.isActive == true) {
return return
} }
@ -103,8 +119,8 @@ class RemoteListViewModel(
listError.value = null listError.value = null
val list = repository.getList2( val list = repository.getList2(
offset = if (append) mangaList.value?.size ?: 0 else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = filter.sortOrder, sortOrder = filterState.sortOrder,
tags = filter.tags, tags = filterState.tags,
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list
@ -121,21 +137,18 @@ class RemoteListViewModel(
} }
} }
private fun createFilterModel(): CurrentFilterModel? { private fun createFilterModel(filterState: FilterState): CurrentFilterModel? {
val tags = filter.tags return if (filterState.tags.isEmpty()) {
return if (tags.isEmpty()) {
null null
} else { } else {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) CurrentFilterModel(filterState.tags.map { ChipsView.ChipModel(0, it.title, it) })
} }
} }
private fun createEmptyState() = listOf( private fun createEmptyState(filterState: FilterState) = EmptyState(
EmptyState( icon = R.drawable.ic_book_cross,
icon = R.drawable.ic_book_cross, textPrimary = R.string.nothing_found,
textPrimary = R.string.nothing_found, textSecondary = 0,
textSecondary = 0, actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,
actionStringRes = if (filter.tags.isEmpty()) 0 else R.string.reset_filter,
)
) )
} }

@ -23,7 +23,7 @@ fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.H
) { ) {
bind { bind {
binding.root.setText(item.titleResId) binding.textViewTitle.setText(item.titleResId)
} }
} }

@ -20,6 +20,13 @@ var TextView.drawableStart: Drawable?
setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3]) setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3])
} }
var TextView.drawableEnd: Drawable?
inline get() = compoundDrawablesRelative[2]
set(value) {
val dr = compoundDrawablesRelative
setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], dr[1], value, dr[3])
}
fun TextView.setTextAndVisible(@StringRes textResId: Int) { fun TextView.setTextAndVisible(@StringRes textResId: Int) {
if (textResId == 0) { if (textResId == 0) {
text = null text = null

@ -55,6 +55,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceHeadlineSmall" android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -67,6 +68,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintEnd_toEndOf="@id/textView_title" app:layout_constraintEnd_toEndOf="@id/textView_title"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

@ -56,6 +56,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="5"
android:textAppearance="?attr/textAppearanceHeadlineSmall" android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -68,11 +69,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="4"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintEnd_toEndOf="@id/textView_title" app:layout_constraintEnd_toEndOf="@id/textView_title"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_title" app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="@tools:sample/lorem[2]" /> tools:text="@tools:sample/lorem[30]" />
<TextView <TextView
android:id="@+id/textView_author" android:id="@+id/textView_author"

@ -55,6 +55,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceHeadlineSmall" android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -67,6 +68,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintEnd_toEndOf="@id/textView_title" app:layout_constraintEnd_toEndOf="@id/textView_title"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

@ -15,7 +15,7 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer" android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true" app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
tools:listitem="@layout/item_manga_list" /> tools:listitem="@layout/item_manga_list" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

@ -1,13 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical|start" android:gravity="center_vertical|start"
android:minHeight="@dimen/header_height" android:minHeight="@dimen/header_height"
android:orientation="horizontal"
android:paddingStart="?android:listPreferredItemPaddingStart" android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd" android:paddingEnd="?android:listPreferredItemPaddingEnd">
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" <TextView
tools:text="@tools:sample/lorem[2]" /> 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…
Cancel
Save