Fix filter

This commit is contained in:
Koitharu
2022-03-10 11:23:36 +02:00
parent d5d19c37d8
commit 755f1e5747
20 changed files with 428 additions and 322 deletions

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,7 @@ import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
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 org.koitharu.kotatsu.list.ui.model.*
import kotlin.jvm.internal.Intrinsics
class MangaListAdapter(
@@ -60,6 +57,16 @@ class MangaListAdapter(
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
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 {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
@@ -45,7 +46,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
) {
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,13 @@ var TextView.drawableStart: Drawable?
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) {
if (textResId == 0) {
text = null

View File

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

View File

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

View File

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

View File

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

View File

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