diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt index 06f22090a..e51857f55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt @@ -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 { + return db.tagsDao.findTags(source.name).mapToSet { + it.toMangaTag() + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt index b3df5277a..8d1c5e279 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -33,7 +33,7 @@ abstract class BaseViewModel : ViewModel() { } } - private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> + protected fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> if (BuildConfig.DEBUG) { throwable.printStackTrace() } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt deleted file mode 100644 index f17b31f84..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt index 0cd94ba37..7f9655d19 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt @@ -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 + @Query("SELECT * FROM tags WHERE source = :source") + abstract suspend fun findTags(source: String): List @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(tag: TagEntity): Long diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt deleted file mode 100644 index 498492f24..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt +++ /dev/null @@ -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, -) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 4a2c2be82..f66e4b14f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -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 get() = emptySet() - override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain() override suspend fun getTags(): Set = emptySet() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt index 00619c34f..8fb6ee6a7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt @@ -17,6 +17,8 @@ class ExHentaiRepository( override val source = MangaSource.EXHENTAI + override val sortOrders: Set = emptySet() + override val defaultDomain: String get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 97fa18c4c..97664dd2f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -85,7 +85,7 @@ class HistoryListViewModel( val result = ArrayList(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) { 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 a91d3924b..00bd769fd 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 @@ -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(), 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(), spanSizeLookup.invalidateCache() } open val isSwipeRefreshEnabled = true - private var drawer: DrawerLayout? = null protected abstract val viewModel: MangaListViewModel @@ -67,16 +59,14 @@ abstract class MangaListFragment : BaseFragment(), 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(), 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(), } override fun onDestroyView() { - drawer = null listAdapter = null - filterAdapter = null paginationListener = null spanSizeLookup.invalidateCache() super.onDestroyView() @@ -125,19 +108,9 @@ abstract class MangaListFragment : BaseFragment(), 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(), } } - protected fun onInitFilter(filter: List) { - 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(), } } + protected open fun onFilterClick() = Unit + private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver.setGridSize(scale, binding.recyclerView) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index e2a463f4e..6a04449b6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -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> - val filter = MutableLiveData>() val listMode = MutableLiveData() 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(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() diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt index 4d25060ac..53ac01484 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -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(R.layout.item_header) { +fun listHeaderAD() = adapterDelegate( + 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(R.layout.item_header textView.setText(item.textRes) } } +} + +fun listHeaderWithFilterAD( + onFilterClickListener: () -> Unit, +) = adapterDelegateViewBinding( + 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) + } } \ No newline at end of file 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 61cd60c03..714f04473 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 @@ -20,6 +20,7 @@ class MangaListAdapter( clickListener: OnListItemClickListener, onRetryClick: (Throwable) -> Unit, onTagRemoveClick: (MangaTag) -> Unit, + onFilterClickListener: () -> Unit, ) : AsyncListDifferDelegationAdapter(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() { @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt similarity index 81% rename from app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt index 67b4d3585..19b3f11f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt @@ -2,11 +2,13 @@ package org.koitharu.kotatsu.list.ui.filter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -class FilterAdapter2( +class FilterAdapter( listener: OnFilterChangedListener, ) : AsyncListDifferDelegationAdapter( FilterDiffCallback(), filterSortDelegate(listener), filterTagDelegate(listener), filterHeaderDelegate(), + filterLoadingDelegate(), + filterErrorDelegate(), ) \ No newline at end of file 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 8b926d768..073de2c9d 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,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(R.layout.item_loading_footer) {} + +fun filterErrorDelegate() = adapterDelegate(R.layout.item_sources_empty) { + + bind { + (itemView as TextView).setText(item.textResId) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt new file mode 100644 index 000000000..70aef4326 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -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() { + + private val viewModel by sharedViewModel( + owner = { from(requireParentFragment(), requireParentFragment()) } + ) { + parametersOf( + requireArguments().getParcelable(ARG_SOURCE), + requireArguments().getParcelable(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) + } +} \ 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 1ccd4e813..73e3db315 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 @@ -6,6 +6,7 @@ class FilterDiffCallback : DiffUtil.ItemCallback() { 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() { 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 } 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 a74d93b1d..75b29e60d 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 @@ -19,4 +19,10 @@ sealed interface FilterItem { val tag: MangaTag, val isChecked: Boolean, ) : FilterItem + + object Loading : FilterItem + + class Error( + @StringRes val textResId: Int, + ) : FilterItem } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt new file mode 100644 index 000000000..1c1c8a9cf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt @@ -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, +) : Parcelable \ No newline at end of file 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 new file mode 100644 index 000000000..06c4b029e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterViewModel.kt @@ -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>() + val result = MutableLiveData() + 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(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) + } + result.value = 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() + } + } + + 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) { + kotlin.runCatching { + repository.getTags() + }.getOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt index 209c7227f..a14db0f3a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 3f721355a..b68af3a0a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -32,7 +32,7 @@ class LocalListViewModel( val importProgress = MutableLiveData(null) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(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( 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 4555fe10e..4d35a857f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt @@ -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())), get()) + viewModel { params -> + RemoteListViewModel( + repository = get(named(params.get())) as RemoteMangaRepository, + settings = get(), + ) + } + + viewModel { params -> + FilterViewModel( + repository = get(named(params.get())) as RemoteMangaRepository, + dataRepository = get(), + state = params.get(), + ) } } \ No newline at end of file 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 5ae3a92da..04a1ffefe 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,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 { parametersOf(source) @@ -20,6 +24,11 @@ class RemoteListFragment : MangaListFragment() { 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() } @@ -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" 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 e92616d4c..c914e709f 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 @@ -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?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(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(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() - } - } - } - } } diff --git a/app/src/main/res/layout-w600dp/fragment_list.xml b/app/src/main/res/layout-w600dp/fragment_list.xml deleted file mode 100644 index c3041768c..000000000 --- a/app/src/main/res/layout-w600dp/fragment_list.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_list.xml b/app/src/main/res/layout/fragment_list.xml index 93abec4b7..a4e4cb419 100644 --- a/app/src/main/res/layout/fragment_list.xml +++ b/app/src/main/res/layout/fragment_list.xml @@ -1,39 +1,21 @@ - - - - - - - + tools:listitem="@layout/item_manga_list" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_checkable_multiple.xml b/app/src/main/res/layout/item_checkable_multiple.xml index 7871b30a6..2feb5f5aa 100644 --- a/app/src/main/res/layout/item_checkable_multiple.xml +++ b/app/src/main/res/layout/item_checkable_multiple.xml @@ -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" diff --git a/app/src/main/res/layout/item_checkable_single.xml b/app/src/main/res/layout/item_checkable_single.xml index a9c19ed8a..cec15830e 100644 --- a/app/src/main/res/layout/item_checkable_single.xml +++ b/app/src/main/res/layout/item_checkable_single.xml @@ -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" diff --git a/app/src/main/res/layout/item_header_with_filter.xml b/app/src/main/res/layout/item_header_with_filter.xml new file mode 100644 index 000000000..05c7793ba --- /dev/null +++ b/app/src/main/res/layout/item_header_with_filter.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml new file mode 100644 index 000000000..b7343028b --- /dev/null +++ b/app/src/main/res/layout/sheet_filter.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_list.xml b/app/src/main/res/menu/opt_list.xml index 09ef204b0..421f8a2c5 100644 --- a/app/src/main/res/menu/opt_list.xml +++ b/app/src/main/res/menu/opt_list.xml @@ -9,9 +9,4 @@ android:title="@string/list_mode" app:showAsAction="never" /> - \ No newline at end of file diff --git a/app/src/main/res/menu/opt_list_remote.xml b/app/src/main/res/menu/opt_list_remote.xml index deb531840..5df3276f1 100644 --- a/app/src/main/res/menu/opt_list_remote.xml +++ b/app/src/main/res/menu/opt_list_remote.xml @@ -3,6 +3,12 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + + Не предлагать NSFW мангу Включено Выключено + Не удалось загрузить список жанров \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43a21031c..939ed3379 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,4 +263,5 @@ Do not suggest NSFW manga Enabled Disabled + Unable to load genres list \ No newline at end of file