From 9ba87640c0cec7332f43e50fd84faf7e7bee5ebf Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 18 Jul 2023 15:34:56 +0300 Subject: [PATCH] Bookmarks bottom sheet --- .../kotatsu/bookmarks/data/BookmarksDao.kt | 10 +- .../bookmarks/ui/adapter/BookmarkListAD.kt | 4 - .../bookmarks/ui/sheet/BookmarkLargeAD.kt | 48 +++++ .../bookmarks/ui/sheet/BookmarksAdapter.kt | 45 +++++ .../bookmarks/ui/sheet/BookmarksSheet.kt | 171 ++++++++++++++++++ .../ui/sheet/BookmarksSheetViewModel.kt | 54 ++++++ .../kotatsu/details/ui/DetailsFragment.kt | 5 + .../main/res/layout/item_bookmark_large.xml | 29 +++ 8 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt create mode 100644 app/src/main/res/layout/item_bookmark_large.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt index db688a984..173e0e444 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -12,24 +12,24 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags @Dao abstract class BookmarksDao { - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId ORDER BY percent") abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity? @Transaction @Query( - "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at", + "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", ) abstract suspend fun findAll(): Map> - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent") abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC") + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent") abstract fun observe(mangaId: Long): Flow> @Transaction @Query( - "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at", + "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", ) abstract fun observe(): Flow>> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt index c9516e3d5..f8b9c411e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt @@ -5,19 +5,15 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.drawable.TextDrawable import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getThemeResId import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemBookmarkBinding -import org.koitharu.kotatsu.parsers.util.format -import com.google.android.material.R as materialR fun bookmarkListAD( coil: ImageLoader, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt new file mode 100644 index 000000000..6c19f1900 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.bookmarks.ui.sheet + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver +import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.decodeRegion +import org.koitharu.kotatsu.core.util.ext.disposeImageRequest +import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.newImageRequest +import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun bookmarkLargeAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) }, +) { + val listener = AdapterDelegateClickListenerAdapter(this, clickListener) + + binding.root.setOnClickListener(listener) + binding.root.setOnLongClickListener(listener) + + bind { + binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { + size(CoverSizeResolver(binding.imageViewThumb)) + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_error_placeholder) + allowRgb565(true) + decodeRegion(item.scroll) + source(item.manga.source) + enqueueWith(coil) + } + binding.progressView.percent = item.percent + } + + onViewRecycled { + binding.imageViewThumb.disposeImageRequest() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt new file mode 100644 index 000000000..dd04756a0 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.bookmarks.ui.sheet + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller +import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD +import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel + +class BookmarksAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) : BaseListAdapter(), FastScroller.SectionIndexer { + + init { + delegatesManager + .addDelegate(ITEM_TYPE_THUMBNAIL, bookmarkLargeAD(coil, lifecycleOwner, clickListener)) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD(null)) + .addDelegate(ITEM_LOADING, loadingFooterAD()) + } + + override fun getSectionText(context: Context, position: Int): CharSequence? { + val list = items + for (i in (0..position).reversed()) { + val item = list.getOrNull(i) ?: continue + if (item is ListHeader) { + return item.getText(context) + } + } + return null + } + + companion object { + + const val ITEM_TYPE_THUMBNAIL = 0 + const val ITEM_TYPE_HEADER = 1 + const val ITEM_LOADING = 2 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt new file mode 100644 index 000000000..9f6d4f970 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt @@ -0,0 +1,171 @@ +package org.koitharu.kotatsu.bookmarks.ui.sheet + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior +import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.plus +import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.databinding.SheetPagesBinding +import org.koitharu.kotatsu.list.ui.MangaListSpanResolver +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail +import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter +import javax.inject.Inject +import kotlin.math.roundToInt + +@AndroidEntryPoint +class BookmarksSheet : + BaseAdaptiveSheet(), + AdaptiveSheetCallback, + OnListItemClickListener { + + private val viewModel by viewModels() + + @Inject + lateinit var coil: ImageLoader + + @Inject + lateinit var settings: AppSettings + + private var bookmarksAdapter: BookmarksAdapter? = null + private var spanResolver: MangaListSpanResolver? = null + + private val spanSizeLookup = SpanSizeLookup() + private val listCommitCallback = Runnable { + spanSizeLookup.invalidateCache() + } + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { + return SheetPagesBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + addSheetCallback(this) + spanResolver = MangaListSpanResolver(binding.root.resources) + bookmarksAdapter = BookmarksAdapter( + coil = coil, + lifecycleOwner = viewLifecycleOwner, + clickListener = this@BookmarksSheet, + ) + viewBinding?.headerBar?.setTitle(R.string.bookmarks) + with(binding.recyclerView) { + addItemDecoration( + SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), + ) + adapter = bookmarksAdapter + addOnLayoutChangeListener(spanResolver) + spanResolver?.setGridSize(settings.gridSize / 100f, this) + (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup + } + viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + } + + override fun onDestroyView() { + spanResolver = null + bookmarksAdapter = null + spanSizeLookup.invalidateCache() + super.onDestroyView() + } + + override fun onItemClick(item: Bookmark, view: View) { + val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener) + if (listener != null) { + listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId)) + } else { + val intent = IntentBuilder(view.context) + .manga(viewModel.manga) + .bookmark(item) + .incognito(true) + .build() + startActivity(intent, scaleUpActivityOptionsOf(view)) + } + dismiss() + } + + override fun onStateChanged(sheet: View, newState: Int) { + viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED + } + + private fun onThumbnailsChanged(list: List) { + val adapter = bookmarksAdapter ?: return + if (adapter.itemCount == 0) { + var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } + if (position > 0) { + val spanCount = spanResolver?.spanCount ?: 0 + val offset = if (position > spanCount + 1) { + (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() + } else { + position = 0 + 0 + } + val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) + adapter.setItems(list, listCommitCallback + scrollCallback) + } else { + adapter.setItems(list, listCommitCallback) + } + } else { + adapter.setItems(list, listCommitCallback) + } + } + + private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { + + init { + isSpanIndexCacheEnabled = true + isSpanGroupIndexCacheEnabled = true + } + + override fun getSpanSize(position: Int): Int { + val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 + return when (bookmarksAdapter?.getItemViewType(position)) { + PageThumbnailAdapter.ITEM_TYPE_THUMBNAIL -> 1 + else -> total + } + } + + fun invalidateCache() { + invalidateSpanGroupIndexCache() + invalidateSpanIndexCache() + } + } + + companion object { + + const val ARG_MANGA = "manga" + + private const val TAG = "BookmarksSheet" + + fun show(fm: FragmentManager, manga: Manga) { + BookmarksSheet().withArgs(1) { + putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true)) + }.showDistinct(fm, TAG) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt new file mode 100644 index 000000000..cc72e0cea --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt @@ -0,0 +1,54 @@ +package org.koitharu.kotatsu.bookmarks.ui.sheet + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.parsers.util.SuspendLazy +import javax.inject.Inject + +@HiltViewModel +class BookmarksSheetViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + bookmarksRepository: BookmarksRepository, +) : BaseViewModel() { + + val manga = savedStateHandle.require(BookmarksSheet.ARG_MANGA).manga + private val chaptersLazy = SuspendLazy { + requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters) + } + + val content: StateFlow> = bookmarksRepository.observeBookmarks(manga) + .map { mapList(it) } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter())) + + private suspend fun mapList(bookmarks: List): List { + val chapters = chaptersLazy.get() + val bookmarksMap = bookmarks.groupBy { it.chapterId } + val result = ArrayList(bookmarks.size + bookmarksMap.size) + for (chapter in chapters) { + val b = bookmarksMap[chapter.id] + if (b.isNullOrEmpty()) { + continue + } + result += ListHeader(chapter.name, 0, null) + result.addAll(b) + } + return result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 078ee7816..bee5b3a4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter +import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet import org.koitharu.kotatsu.core.model.countChaptersByBranch import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseListAdapter @@ -303,6 +304,10 @@ class DetailsFragment : R.id.button_scrobbling_more -> { ScrobblingSelectorSheet.show(parentFragmentManager, manga, null) } + + R.id.button_bookmarks_more -> { + BookmarksSheet.show(parentFragmentManager, manga) + } } } diff --git a/app/src/main/res/layout/item_bookmark_large.xml b/app/src/main/res/layout/item_bookmark_large.xml new file mode 100644 index 000000000..370762f4a --- /dev/null +++ b/app/src/main/res/layout/item_bookmark_large.xml @@ -0,0 +1,29 @@ + + + + + + + +