Merge branch 'feature/bs-filter' into devel

This commit is contained in:
Koitharu
2022-03-04 18:35:57 +02:00
34 changed files with 451 additions and 295 deletions

View File

@@ -6,7 +6,10 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) {
@@ -45,4 +48,10 @@ class MangaDataRepository(private val db: MangaDatabase) {
db.mangaDao.upsert(MangaEntity.from(manga), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet {
it.toMangaTag()
}
}
}

View File

@@ -33,7 +33,7 @@ abstract class BaseViewModel : ViewModel() {
}
}
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
protected fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (BuildConfig.DEBUG) {
throwable.printStackTrace()
}

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isGone
import com.google.android.material.R
import com.google.android.material.appbar.MaterialToolbar
import java.lang.reflect.Field
class AnimatedToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.toolbarStyle,
) : MaterialToolbar(context, attrs, defStyleAttr) {
private var navButtonView: View? = null
get() {
if (field == null) {
runCatching {
field = navButtonViewField?.get(this) as? View
}
}
return field
}
override fun setNavigationIcon(icon: Drawable?) {
super.setNavigationIcon(icon)
navButtonView?.isGone = (icon == null)
}
private companion object {
val navButtonViewField: Field? = runCatching {
Toolbar::class.java.getDeclaredField("mNavButtonView")
.also { it.isAccessible = true }
}.getOrNull()
}
}

View File

@@ -6,8 +6,8 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class TagsDao {
@Query("SELECT * FROM tags")
abstract suspend fun getAllTags(): List<TagEntity>
@Query("SELECT * FROM tags WHERE source = :source")
abstract suspend fun findTags(source: String): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(tag: TagEntity): Long

View File

@@ -1,10 +0,0 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class MangaFilter(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable

View File

@@ -4,7 +4,6 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.SourceSettings
abstract class RemoteMangaRepository(
@@ -20,8 +19,6 @@ abstract class RemoteMangaRepository(
val title: String
get() = source.title
override val sortOrders: Set<SortOrder> get() = emptySet()
override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain()
override suspend fun getTags(): Set<MangaTag> = emptySet()

View File

@@ -17,6 +17,8 @@ class ExHentaiRepository(
override val source = MangaSource.EXHENTAI
override val sortOrders: Set<SortOrder> = emptySet()
override val defaultDomain: String
get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED

View File

@@ -85,7 +85,7 @@ class HistoryListViewModel(
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history)
result += ListHeader(null, R.string.history, null)
}
for ((manga, history) in list) {
if (grouped) {

View File

@@ -4,13 +4,9 @@ import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -30,8 +26,6 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2
import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
@@ -43,7 +37,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
SwipeRefreshLayout.OnRefreshListener {
private var listAdapter: MangaListAdapter? = null
private var filterAdapter: FilterAdapter2? = null
private var paginationListener: PaginationScrollListener? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
@@ -51,7 +44,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
spanSizeLookup.invalidateCache()
}
open val isSwipeRefreshEnabled = true
private var drawer: DrawerLayout? = null
protected abstract val viewModel: MangaListViewModel
@@ -67,16 +59,14 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
listAdapter = MangaListAdapter(
coil = get(),
lifecycleOwner = viewLifecycleOwner,
clickListener = this,
onRetryClick = ::resolveException,
onTagRemoveClick = viewModel::onRemoveFilterTag
onTagRemoveClick = viewModel::onRemoveFilterTag,
onFilterClickListener = this::onFilterClick,
)
filterAdapter = FilterAdapter2(viewModel)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
@@ -89,17 +79,12 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled
}
with(binding.recyclerViewFilter) {
setHasFixedSize(true)
adapter = filterAdapter
}
(parentFragment as? RecycledViewPoolHolder)?.let {
binding.recyclerView.setRecycledViewPool(it.recycledViewPool)
}
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.filter.observe(viewLifecycleOwner, ::onInitFilter)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
@@ -107,9 +92,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
override fun onDestroyView() {
drawer = null
listAdapter = null
filterAdapter = null
paginationListener = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
@@ -125,19 +108,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
ListModeSelectDialog.show(childFragmentManager)
true
}
R.id.action_filter -> {
drawer?.toggleDrawer(GravityCompat.END)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.action_filter).isVisible = drawer != null &&
drawer?.getDrawerLockMode(GravityCompat.END) != DrawerLayout.LOCK_MODE_LOCKED_CLOSED
super.onPrepareOptionsMenu(menu)
}
override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
@@ -200,27 +173,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
}
protected fun onInitFilter(filter: List<FilterItem>) {
filterAdapter?.items = filter
drawer?.setDrawerLockMode(
if (filter.isEmpty()) {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
} else {
DrawerLayout.LOCK_MODE_UNLOCKED
}
) ?: binding.dividerFilter?.let {
it.isGone = filter.isEmpty()
binding.recyclerViewFilter.isVisible = it.isVisible
}
activity?.invalidateOptionsMenu()
}
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerViewFilter.updatePadding(
top = headerHeight,
bottom = insets.bottom
)
binding.root.updatePadding(
left = insets.left,
right = insets.right
@@ -238,6 +192,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
}
protected open fun onFilterClick() = Unit
private fun onGridScaleChanged(scale: Float) {
spanSizeLookup.invalidateCache()
spanResolver.setGridSize(scale, binding.recyclerView)

View File

@@ -1,32 +1,22 @@
package org.koitharu.kotatsu.list.ui
import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.domain.AvailableFilters
import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel(
private val settings: AppSettings,
) : BaseViewModel(), OnFilterChangedListener {
) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>>
val filter = MutableLiveData<List<FilterItem>>()
val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe()
.filter { it == AppSettings.KEY_GRID_SIZE }
@@ -35,6 +25,8 @@ abstract class MangaListViewModel(
settings.gridSize / 100f
}
open fun onRemoveFilterTag(tag: MangaTag) = Unit
protected fun createListModeFlow() = settings.observe()
.filter { it == AppSettings.KEY_LIST_MODE }
.map { settings.listMode }
@@ -46,63 +38,6 @@ abstract class MangaListViewModel(
}
}
protected var currentFilter: MangaFilter = MangaFilter(null, emptySet())
private set(value) {
field = value
onFilterChanged()
}
protected var availableFilters: AvailableFilters? = null
private var filterJob: Job? = null
final override fun onSortItemClick(item: FilterItem.Sort) {
currentFilter = currentFilter.copy(sortOrder = item.order)
}
final override fun onTagItemClick(item: FilterItem.Tag) {
val tags = if (item.isChecked) {
currentFilter.tags - item.tag
} else {
currentFilter.tags + item.tag
}
currentFilter = currentFilter.copy(tags = tags)
}
fun onRemoveFilterTag(tag: MangaTag) {
val tags = currentFilter.tags
if (tag !in tags) {
return
}
currentFilter = currentFilter.copy(tags = tags - tag)
}
@CallSuper
open fun onFilterChanged() {
val previousJob = filterJob
filterJob = launchJob(Dispatchers.Default) {
previousJob?.cancelAndJoin()
filter.postValue(
availableFilters?.run {
val list = ArrayList<FilterItem>(size + 2)
if (sortOrders.isNotEmpty()) {
val selectedSort = currentFilter.sortOrder ?: sortOrders.first()
list += FilterItem.Header(R.string.sort_order)
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
FilterItem.Sort(it, isSelected = it == selectedSort)
}
}
if (tags.isNotEmpty()) {
list += FilterItem.Header(R.string.genres)
tags.sortedBy { it.title }.mapTo(list) {
FilterItem.Tag(it, isChecked = it in currentFilter.tags)
}
}
ensureActive()
list
}.orEmpty()
)
}
}
abstract fun onRefresh()
abstract fun onRetry()

View File

@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemHeaderWithFilterBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header) {
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(
layout = R.layout.item_header,
on = { item, _, _ -> item is ListHeader && item.sortOrder == null },
) {
bind {
val textView = (itemView as TextView)
@@ -16,4 +21,25 @@ fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header
textView.setText(item.textRes)
}
}
}
fun listHeaderWithFilterAD(
onFilterClickListener: () -> Unit,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderWithFilterBinding>(
viewBinding = { inflater, parent -> ItemHeaderWithFilterBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ListHeader && item.sortOrder != null },
) {
binding.textViewFilter.setOnClickListener {
onFilterClickListener()
}
bind {
if (item.text != null) {
binding.textViewTitle.text = item.text
} else {
binding.textViewTitle.setText(item.textRes)
}
binding.textViewFilter.setText(requireNotNull(item.sortOrder).titleRes)
}
}

View File

@@ -20,6 +20,7 @@ class MangaListAdapter(
clickListener: OnListItemClickListener<Manga>,
onRetryClick: (Throwable) -> Unit,
onTagRemoveClick: (MangaTag) -> Unit,
onFilterClickListener: () -> Unit,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
@@ -41,6 +42,7 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
.addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(onFilterClickListener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@@ -79,5 +81,6 @@ class MangaListAdapter(
const val ITEM_TYPE_EMPTY = 8
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10
const val ITEM_TYPE_HEADER_FILTER = 11
}
}

View File

@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.list.ui.filter
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter2(
class FilterAdapter(
listener: OnFilterChangedListener,
) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(),
filterSortDelegate(listener),
filterTagDelegate(listener),
filterHeaderDelegate(),
filterLoadingDelegate(),
filterErrorDelegate(),
)

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
@@ -44,4 +47,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
bind {
binding.root.setText(item.titleResId)
}
}
fun filterLoadingDelegate() = adapterDelegate<FilterItem.Loading, FilterItem>(R.layout.item_loading_footer) {}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, FilterItem>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
}

View File

@@ -0,0 +1,84 @@
package org.koitharu.kotatsu.list.ui.filter
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.utils.ext.withArgs
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>() {
private val viewModel by sharedViewModel<FilterViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) }
) {
parametersOf(
requireArguments().getParcelable<MangaSource>(ARG_SOURCE),
requireArguments().getParcelable<FilterState>(ARG_STATE),
)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener { dismiss() }
if (!resources.getBoolean(R.bool.is_tablet)) {
binding.toolbar.navigationIcon = null
}
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))
}
}
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
} else {
binding.toolbar.navigationIcon = null
}
}
}
)
}
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)
}
}

View File

@@ -6,6 +6,7 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when {
oldItem === newItem -> true
oldItem.javaClass != newItem.javaClass -> false
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.titleResId == newItem.titleResId
@@ -16,13 +17,18 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.order == newItem.order
}
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
oldItem.textResId == newItem.textResId
}
else -> false
}
}
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.Error && newItem is FilterItem.Error -> true
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked == newItem.isChecked
}

View File

@@ -19,4 +19,10 @@ sealed interface FilterItem {
val tag: MangaTag,
val isChecked: Boolean,
) : FilterItem
object Loading : FilterItem
class Error(
@StringRes val textResId: Int,
) : FilterItem
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.list.ui.filter
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
@Parcelize
class FilterState(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
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 java.util.*
class FilterViewModel(
private val repository: RemoteMangaRepository,
dataRepository: MangaDataRepository,
state: FilterState,
) : BaseViewModel(), OnFilterChangedListener {
val filter = MutableLiveData<List<FilterItem>>()
val result = MutableLiveData<FilterState>()
private var job: Job? = null
private var selectedSortOrder: SortOrder? = state.sortOrder
private val selectedTags = HashSet(state.tags)
private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) {
dataRepository.findTags(repository.source)
}
private var availableTagsDeferred = loadTagsAsync()
init {
showFilter()
}
override fun onSortItemClick(item: FilterItem.Sort) {
selectedSortOrder = item.order
updateFilters()
}
override fun onTagItemClick(item: FilterItem.Tag) {
val isModified = if (item.isChecked) {
selectedTags.remove(item.tag)
} else {
selectedTags.add(item.tag)
}
if (isModified) {
updateFilters()
}
}
private fun updateFilters() {
val previousJob = job
job = launchJob(Dispatchers.Default) {
previousJob?.cancelAndJoin()
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)
}
result.value = 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()
}
}
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) {
kotlin.runCatching {
repository.getTags()
}.getOrNull()
}
}

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.SortOrder
data class ListHeader(
val text: CharSequence?,
@StringRes val textRes: Int,
val sortOrder: SortOrder?,
) : ListModel

View File

@@ -32,7 +32,7 @@ class LocalListViewModel(
val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val headerModel = ListHeader(null, R.string.local_storage)
private val headerModel = ListHeader(null, R.string.local_storage, null)
private var importJob: Job? = null
override val content = combine(

View File

@@ -4,12 +4,26 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
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
get() = module {
viewModel { source ->
RemoteListViewModel(get(named(source.get<MangaSource>())), get())
viewModel { params ->
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(),
state = params.get(),
)
}
}

View File

@@ -1,18 +1,22 @@
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.reader.ui.SimpleSettingsActivity
import org.koitharu.kotatsu.utils.ext.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment() {
class RemoteListFragment : MangaListFragment(), FragmentResultListener {
override val viewModel by viewModel<RemoteListViewModel> {
parametersOf(source)
@@ -20,6 +24,11 @@ class RemoteListFragment : MangaListFragment() {
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()
}
@@ -44,10 +53,26 @@ class RemoteListFragment : MangaListFragment() {
)
true
}
R.id.action_filter -> {
onFilterClick()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onFilterClick() {
FilterBottomSheet.show(childFragmentManager, source, viewModel.filter)
}
override fun onFragmentResult(requestKey: String, result: Bundle) {
when (requestKey) {
FilterBottomSheet.REQUEST_KEY -> viewModel.applyFilter(
result.getParcelable(FilterBottomSheet.ARG_STATE) ?: return
)
}
}
companion object {
private const val ARG_SOURCE = "provider"

View File

@@ -9,38 +9,43 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.parser.MangaRepository
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.domain.AvailableFilters
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class RemoteListViewModel(
private val repository: MangaRepository,
private val repository: RemoteMangaRepository,
settings: AppSettings
) : MangaListViewModel(settings) {
var filter = FilterState(repository.sortOrders.firstOrNull(), emptySet())
private set
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 = ListHeader((repository as RemoteMangaRepository).title, 0)
private val headerModel = MutableStateFlow(
ListHeader(repository.title, 0, filter.sortOrder)
)
override val content = combine(
mangaList,
createListModeFlow(),
headerModel,
listError,
hasNextPage
) { list, mode, error, hasNext ->
) { list, mode, header, error, hasNext ->
when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string.empty))
else -> {
val result = ArrayList<ListModel>(list.size + 3)
result += headerModel
result += header
createFilterModel()?.let { result.add(it) }
list.toUi(result, mode)
when {
@@ -54,7 +59,6 @@ class RemoteListViewModel(
init {
loadList(false)
loadFilter()
}
override fun onRefresh() {
@@ -65,12 +69,28 @@ class RemoteListViewModel(
loadList(append = !mangaList.value.isNullOrEmpty())
}
override fun onRemoveFilterTag(tag: MangaTag) {
val tags = filter.tags
if (tag !in tags) {
return
}
applyFilter(FilterState(filter.sortOrder, tags - tag))
}
fun loadNextPage() {
if (hasNextPage.value && listError.value == null) {
loadList(append = true)
}
}
fun applyFilter(newFilter: FilterState) {
filter = newFilter
headerModel.value = ListHeader(repository.title, 0, newFilter.sortOrder)
mangaList.value = null
hasNextPage.value = false
loadList(false)
}
private fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
@@ -80,8 +100,8 @@ class RemoteListViewModel(
listError.value = null
val list = repository.getList2(
offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = currentFilter.sortOrder,
tags = currentFilter.tags,
sortOrder = filter.sortOrder,
tags = filter.tags,
)
if (!append) {
mangaList.value = list
@@ -98,34 +118,12 @@ class RemoteListViewModel(
}
}
override fun onFilterChanged() {
super.onFilterChanged()
mangaList.value = null
hasNextPage.value = false
loadList(false)
}
private fun createFilterModel(): CurrentFilterModel? {
val tags = currentFilter.tags
val tags = filter.tags
return if (tags.isEmpty()) {
null
} else {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
}
}
private fun loadFilter() {
launchJob(Dispatchers.Default) {
try {
val sorts = repository.sortOrders
val tags = repository.getTags()
availableFilters = AvailableFilters(sorts, tags)
onFilterChanged()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
}
}
}
}

View File

@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:orientation="horizontal">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<View
android:id="@+id/divider_filter"
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="?attr/colorOutline"
android:visibility="gone"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter"
android:layout_width="240dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:windowBackground"
android:orientation="vertical"
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable"
tools:visibility="visible" />
</LinearLayout>

View File

@@ -1,39 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter"
android:layout_width="240dp"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:windowBackground"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
android:padding="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" />
tools:listitem="@layout/item_manga_list" />
</androidx.drawerlayout.widget.DrawerLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground"
android:background="?selectableItemBackground"
android:drawableStart="?android:listChoiceIndicatorMultiple"
android:drawablePadding="12dp"
android:gravity="center_vertical|start"

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground"
android:background="?selectableItemBackground"
android:drawableStart="?android:listChoiceIndicatorSingle"
android:drawablePadding="12dp"
android:gravity="center_vertical|start"

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/textView_filter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
tools:text="@tools:sample/lorem[21]" />
<TextView
android:id="@+id/textView_filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/list_selector"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
app:drawableEndCompat="@drawable/ic_drop_down"
app:drawableTint="?android:attr/textColorSecondary"
tools:ignore="RtlSymmetry"
tools:text="@string/popular" />
</RelativeLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:navigationIcon="@drawable/ic_cross"
app:title="@string/filter" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" />
</LinearLayout>

View File

@@ -9,9 +9,4 @@
android:title="@string/list_mode"
app:showAsAction="never" />
<item
android:id="@+id/action_filter"
android:orderInCategory="30"
android:title="@string/filter"
app:showAsAction="never" />
</menu>

View File

@@ -3,6 +3,12 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_filter"
android:orderInCategory="30"
android:title="@string/filter"
app:showAsAction="never" />
<item
android:id="@+id/action_source_settings"
android:orderInCategory="50"

View File

@@ -261,4 +261,5 @@
<string name="exclude_nsfw_from_suggestions">Не предлагать NSFW мангу</string>
<string name="enabled">Включено</string>
<string name="disabled">Выключено</string>
<string name="filter_load_error">Не удалось загрузить список жанров</string>
</resources>

View File

@@ -263,4 +263,5 @@
<string name="exclude_nsfw_from_suggestions">Do not suggest NSFW manga</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="filter_load_error">Unable to load genres list</string>
</resources>