diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt new file mode 100644 index 000000000..fc6564beb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt new file mode 100644 index 000000000..64e73198a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index f2c453db1..70b2d8c93 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -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(), 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index a95612318..5c801760d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt index 073de2c9d..8898c5d07 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt @@ -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(), MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { - private val viewModel by sharedViewModel( + private val viewModel by sharedViewModel( owner = { from(requireParentFragment(), requireParentFragment()) } - ) { - parametersOf( - requireArguments().getParcelable(ARG_SOURCE), - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val state = requireArguments().getParcelable(ARG_STATE) - viewModel.updateState(state) - } + ) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return super.onCreateDialog(savedInstanceState).also { @@ -53,10 +40,7 @@ class FilterBottomSheet : BaseBottomSheet(), 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(), 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(), 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) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt new file mode 100644 index 000000000..8e6075362 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt @@ -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 { + val sortOrders = repository.sortOrders.sortedBy { it.ordinal } + val tags = mergeTags(state.tags, allTags.tags).sortedBy { it.title } + val list = ArrayList(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? { + 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, secondary: Set): Set { + val result = TreeSet(TagTitleComparator()) + result.addAll(secondary) + result.addAll(primary) + return result + } + + private class TagsWrapper( + val tags: Set, + 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 { + + override fun compare(o1: MangaTag, o2: MangaTag) = compareValues( + o1.title.lowercase(), + o2.title.lowercase(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt index 73e3db315..4549c46cf 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt @@ -27,7 +27,9 @@ class FilterDiffCallback : DiffUtil.ItemCallback() { 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() { } 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) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt index 75b29e60d..c9819304d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt @@ -8,6 +8,7 @@ sealed interface FilterItem { class Header( @StringRes val titleResId: Int, + val counter: Int, ) : FilterItem class Sort( diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt deleted file mode 100644 index 97d41e966..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt +++ /dev/null @@ -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>() - val result = MutableLiveData() - private var job: Job? = null - private var selectedSortOrder: SortOrder? = repository.sortOrders.firstOrNull() - private val selectedTags = HashSet() - private var searchQuery: String = "" - private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) { - dataRepository.findTags(repository.source) - } - 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(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(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(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() - val mappedTags = TreeSet(compareBy({ !it.isChecked }, { it.tag.title })) - localTags.mapNotNullTo(mappedTags) { - if (it.title.contains(query, ignoreCase = true)) { - FilterItem.Tag(it, isChecked = it in selectedTags) - } else { - null - } - } - tags?.mapNotNullTo(mappedTags) { - if (it.title.contains(query, ignoreCase = true)) { - FilterItem.Tag(it, isChecked = it in selectedTags) - } else { - null - } - } - selectedTags.mapNotNullTo(mappedTags) { - if (it.title.contains(query, ignoreCase = true)) { - FilterItem.Tag(it, isChecked = true) - } else { - null - } - } - list.addAll(mappedTags) - if (tags == null) { - list.add(FilterItem.Error(R.string.filter_load_error)) - } - if (list.isEmpty()) { - list.add(FilterItem.Error(R.string.nothing_found)) - } - currentCoroutineContext().ensureActive() - filter.postValue(list) - } - - private suspend fun tryLoadTags(): Set? { - 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() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt index 91ded9a42..befa7151c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt @@ -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(named(params.get())) as RemoteMangaRepository, settings = get(), - ) - } - - viewModel { params -> - FilterViewModel( - repository = get(named(params.get())) as RemoteMangaRepository, dataRepository = get(), ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 8d4d22f3a..b1b2867e6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -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 { parametersOf(source) @@ -25,11 +21,6 @@ class RemoteListFragment : MangaListFragment(), FragmentResultListener { private val source by parcelableArgument(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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 3d1b2b155..025dfd515 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -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?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null - private val headerModel = MutableStateFlow( - ListHeader(repository.title, 0, filter.sortOrder) - ) + + val filterItems: LiveData> + 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(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) + + fun resetFilter() = filter.reset() - private fun loadList(append: Boolean) { + 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, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index aa8c9fb35..fd8c8e4bc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -23,7 +23,7 @@ fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding + tools:text="@tools:sample/lorem[30]" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_filter_header.xml b/app/src/main/res/layout/item_filter_header.xml index aac2b3ecb..d1f878bf4 100644 --- a/app/src/main/res/layout/item_filter_header.xml +++ b/app/src/main/res/layout/item_filter_header.xml @@ -1,13 +1,36 @@ - \ No newline at end of file + android:paddingEnd="?android:listPreferredItemPaddingEnd"> + + + + + + \ No newline at end of file