Rename Library to Shelf

This commit is contained in:
Zakhar Timoshenko
2022-09-14 18:46:32 +03:00
parent 734765dbdd
commit 3f6a103915
24 changed files with 123 additions and 123 deletions

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.shelf.domain
import javax.inject.Inject
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
class ShelfRepository @Inject constructor(
private val db: MangaDatabase,
) {
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
val cats = categories.filter { it.isVisibleInLibrary }
if (cats.isEmpty()) {
flowOf(emptyMap())
} else {
observeCategoriesContent(cats)
}
}
}
private fun observeCategoriesContent(
categories: List<FavouriteCategoryEntity>,
) = combine<Pair<FavouriteCategory, List<Manga>>, Map<FavouriteCategory, List<Manga>>>(
categories.map { cat ->
val category = cat.toFavouriteCategory()
db.favouritesDao.observeAll(category.id, category.order)
.map { category to it.map { x -> x.manga.toManga(x.tags.toMangaTags()) } }
},
) { array -> array.toMap() }
}

View File

@@ -0,0 +1,148 @@
package org.koitharu.kotatsu.shelf.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentShelfBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@AndroidEntryPoint
class ShelfFragment :
BaseFragment<FragmentShelfBinding>(),
RecyclerViewOwner,
ShelfListEventListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<ShelfViewModel>()
private var adapter: ShelfAdapter? = null
private var selectionController: SectionedSelectionController<ShelfSectionModel>? = null
override val recyclerView: RecyclerView
get() = binding.recyclerView
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentShelfBinding {
return FragmentShelfBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val sizeResolver = ItemSizeResolver(resources, settings)
selectionController = SectionedSelectionController(
activity = requireActivity(),
owner = this,
callback = ShelfSelectionCallback(binding.recyclerView, childFragmentManager, viewModel),
)
adapter = ShelfAdapter(
lifecycleOwner = viewLifecycleOwner,
coil = coil,
listener = this,
sizeResolver = sizeResolver,
selectionController = checkNotNull(selectionController),
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
addMenuProvider(ShelfMenuProvider(view.context, childFragmentManager, viewModel))
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
super.onDestroyView()
adapter = null
selectionController = null
}
override fun onItemClick(item: Manga, section: ShelfSectionModel, view: View) {
if (selectionController?.onItemClick(section, item.id) != true) {
val intent = DetailsActivity.newIntent(view.context, item)
startActivity(intent)
}
}
override fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean {
return selectionController?.onItemLongClick(section, item.id) ?: false
}
override fun onSectionClick(section: ShelfSectionModel, view: View) {
selectionController?.clear()
val intent = when (section) {
is ShelfSectionModel.History -> HistoryActivity.newIntent(view.context)
is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
}
startActivity(intent)
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = Unit
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
}
private fun onListChanged(list: List<ListModel>) {
adapter?.items = list
}
private fun onError(e: Throwable) {
val snackbar = Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT,
)
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
companion object {
fun newInstance() = ShelfFragment()
}
}

View File

@@ -0,0 +1,77 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.*
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.shelf.ui.config.categories.ShelfCategoriesConfigSheet
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.utils.ext.startOfDay
class ShelfMenuProvider(
private val context: Context,
private val fragmentManager: FragmentManager,
private val viewModel: ShelfViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_shelf, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_clear_history -> {
showClearHistoryDialog()
true
}
R.id.action_grid_size -> {
ShelfSizeBottomSheet.show(fragmentManager)
true
}
R.id.action_import -> {
ImportDialogFragment.show(fragmentManager)
true
}
R.id.action_categories -> {
ShelfCategoriesConfigSheet.show(fragmentManager)
true
}
else -> false
}
}
private fun showClearHistoryDialog() {
val selectionListener = RememberSelectionDialogListener(2)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history)
.setSingleChoiceItems(
arrayOf(
context.getString(R.string.last_2_hours),
context.getString(R.string.today),
context.getString(R.string.clear_all_history),
),
selectionListener.selection,
selectionListener,
)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) {
0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
1 -> Date().startOfDay()
2 -> 0L
else -> return@setPositiveButton
}
viewModel.clearHistory(minDate)
}.show()
}
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Context
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.flattenTo
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
class ShelfSelectionCallback(
private val recyclerView: RecyclerView,
private val fragmentManager: FragmentManager,
private val viewModel: ShelfViewModel,
) : SectionedSelectionController.Callback<ShelfSectionModel> {
private val context: Context
get() = recyclerView.context
override fun onCreateActionMode(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_shelf, menu)
return true
}
override fun onPrepareActionMode(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
menu: Menu,
): Boolean {
menu.findItem(R.id.action_remove).isVisible =
controller.peekCheckedIds().count { (_, v) -> v.isNotEmpty() } == 1
return super.onPrepareActionMode(controller, mode, menu)
}
override fun onActionItemClicked(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_share -> {
ShareHelper(context).shareMangaLinks(collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(fragmentManager, collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(context, collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_remove -> {
val (group, ids) = controller.snapshot().entries.singleOrNull { it.value.isNotEmpty() } ?: return false
when (group) {
is ShelfSectionModel.Favourites -> viewModel.removeFromFavourites(group.category, ids)
is ShelfSectionModel.History -> viewModel.removeFromHistory(ids)
}
mode.finish()
true
}
else -> false
}
}
override fun onSelectionChanged(controller: SectionedSelectionController<ShelfSectionModel>, count: Int) {
recyclerView.invalidateNestedItemDecorations()
}
override fun onCreateItemDecoration(
controller: SectionedSelectionController<ShelfSectionModel>,
section: ShelfSectionModel,
): AbstractSelectionItemDecoration = MangaSelectionDecoration(context)
private fun collectSelectedItemsMap(
controller: SectionedSelectionController<ShelfSectionModel>,
): Map<ShelfSectionModel, Set<Manga>> {
val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) {
return emptyMap()
}
return snapshot.mapValues { (_, ids) -> viewModel.getManga(ids) }
}
private fun collectSelectedItems(
controller: SectionedSelectionController<ShelfSectionModel>,
): Set<Manga> {
val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) {
return emptySet()
}
return viewModel.getManga(snapshot.values.flattenTo(HashSet()))
}
}

View File

@@ -0,0 +1,190 @@
package org.koitharu.kotatsu.shelf.ui
import androidx.collection.ArraySet
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
private const val HISTORY_MAX_SEGMENTS = 2
@HiltViewModel
class ShelfViewModel @Inject constructor(
repository: ShelfRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
) : BaseViewModel(), ListExtraProvider {
val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ListModel>> = combine(
historyRepository.observeAllWithHistory(),
repository.observeFavourites(),
) { history, favourites ->
mapList(history, favourites)
}.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
fun removeFromFavourites(category: FavouriteCategory, ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob(Dispatchers.Default) {
val handle = favouritesRepository.removeFromCategory(category.id, ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle))
}
}
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
}
}
fun clearHistory(minDate: Long) {
launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) {
historyRepository.clear()
R.string.history_cleared
} else {
historyRepository.deleteAfter(minDate)
R.string.removed_from_history
}
onActionDone.postCall(ReversibleAction(stringRes, null))
}
}
fun getManga(ids: Set<Long>): Set<Manga> {
val snapshot = content.value ?: return emptySet()
val result = ArraySet<Manga>(ids.size)
for (section in snapshot) {
if (section !is ShelfSectionModel) {
continue
}
for (item in section.items) {
if (item.id in ids) {
result.add(item.manga)
if (result.size == ids.size) {
return result
}
}
}
}
return result
}
private suspend fun mapList(
history: List<MangaWithHistory>,
favourites: Map<FavouriteCategory, List<Manga>>,
): List<ListModel> {
val result = ArrayList<ListModel>(favourites.keys.size + 1)
if (history.isNotEmpty()) {
mapHistory(result, history)
}
if (favourites.isNotEmpty()) {
mapFavourites(result, favourites)
}
if (result.isEmpty()) {
result += EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_shelf_holder_primary,
textSecondary = R.string.text_shelf_holder_secondary,
actionStringRes = 0,
)
}
result.trimToSize()
return result
}
private suspend fun mapHistory(
destination: MutableList<in ShelfSectionModel.History>,
list: List<MangaWithHistory>,
) {
val showPercent = settings.isReadingIndicatorsEnabled
val groups = list.groupByTo(LinkedHashMap()) { timeAgo(it.history.updatedAt) }
while (groups.size > HISTORY_MAX_SEGMENTS) {
val lastKey = groups.keys.last()
val subList = groups.remove(lastKey) ?: continue
groups[groups.keys.last()]?.addAll(subList)
}
for ((timeAgo, subList) in groups) {
destination += ShelfSectionModel.History(
items = subList.map { (manga, history) ->
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
timeAgo = timeAgo,
showAllButtonText = R.string.show_all,
)
}
}
private suspend fun mapFavourites(
destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>,
) {
for ((category, list) in favourites) {
if (list.isNotEmpty()) {
destination += ShelfSectionModel.Favourites(
items = list.toUi(ListMode.GRID, this),
category = category,
showAllButtonText = R.string.show_all,
)
}
}
}
private fun timeAgo(date: Date): DateTimeAgo {
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays <= 3 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.LongAgo
}
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import kotlin.jvm.internal.Intrinsics
class MangaItemDiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
oldItem as MangaItemModel
newItem as MangaItemModel
return oldItem.javaClass == newItem.javaClass && oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
oldItem as MangaItemModel
newItem as MangaItemModel
return when {
oldItem.progress != newItem.progress -> MangaListAdapter.PAYLOAD_PROGRESS
oldItem.counter != newItem.counter -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class ScrollKeepObserver(
private val recyclerView: RecyclerView,
) : RecyclerView.AdapterDataObserver() {
private val layoutManager: LinearLayoutManager
get() = recyclerView.layoutManager as LinearLayoutManager
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val position = minOf(toPosition, fromPosition) // if items are swapping positions may be swapped too
if (position < layoutManager.findFirstVisibleItemPosition()) {
postScroll(position)
}
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart < layoutManager.findFirstVisibleItemPosition()) {
postScroll(positionStart)
}
}
private fun postScroll(targetPosition: Int) {
recyclerView.post {
layoutManager.scrollToPositionWithOffset(targetPosition, 0)
}
}
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class ShelfAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
listener: ShelfListEventListener,
sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer {
init {
val pool = RecyclerView.RecycledViewPool()
delegatesManager
.addDelegate(
shelfGroupAD(
sharedPool = pool,
lifecycleOwner = lifecycleOwner,
coil = coil,
sizeResolver = sizeResolver,
selectionController = selectionController,
listener = listener,
),
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(coil, listener))
.addDelegate(errorStateListAD(listener))
}
override fun getSectionText(context: Context, position: Int): CharSequence {
val item = items.getOrNull(position) as? ShelfSectionModel
return item?.getTitle(context.resources) ?: ""
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is ShelfSectionModel && newItem is ShelfSectionModel -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when {
oldItem is ShelfSectionModel && newItem is ShelfSectionModel -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}
}
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.removeItemDecoration
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun shelfGroupAD(
sharedPool: RecyclerView.RecycledViewPool,
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>,
listener: ShelfListEventListener,
) = adapterDelegateViewBinding<ShelfSectionModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) },
) {
val listenerAdapter = object : OnListItemClickListener<Manga>, View.OnClickListener {
override fun onItemClick(item: Manga, view: View) {
listener.onItemClick(item, this@adapterDelegateViewBinding.item, view)
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
return listener.onItemLongClick(item, this@adapterDelegateViewBinding.item, view)
}
override fun onClick(v: View?) {
listener.onSectionClick(item, itemView)
}
}
val adapter = AsyncListDifferDelegationAdapter(
MangaItemDiffCallback(),
mangaGridItemAD(coil, lifecycleOwner, listenerAdapter, sizeResolver),
)
adapter.registerAdapterDataObserver(ScrollKeepObserver(binding.recyclerView))
binding.recyclerView.setRecycledViewPool(sharedPool)
binding.recyclerView.adapter = adapter
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
binding.buttonMore.setOnClickListener(listenerAdapter)
bind {
selectionController.attachToRecyclerView(item, binding.recyclerView)
binding.textViewTitle.text = item.getTitle(context.resources)
binding.buttonMore.setTextAndVisible(item.showAllButtonText)
adapter.items = item.items
}
onViewRecycled {
adapter.items = emptyList()
binding.recyclerView.removeItemDecoration(AbstractSelectionItemDecoration::class.java)
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
interface ShelfListEventListener : ListStateHolderListener {
fun onItemClick(item: Manga, section: ShelfSectionModel, view: View)
fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean
fun onSectionClick(section: ShelfSectionModel, view: View)
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
class ShelfCategoriesConfigAdapter(
listener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
}
class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary && oldItem.title == newItem.title
}
override fun getChangePayload(oldItem: FavouriteCategory, newItem: FavouriteCategory): Any? {
return if (oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary) {
super.getChangePayload(oldItem, newItem)
} else Unit
}
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.SheetBaseBinding
@AndroidEntryPoint
class ShelfCategoriesConfigSheet :
BaseBottomSheet<SheetBaseBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener {
private val viewModel by viewModels<ShelfCategoriesConfigViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding {
return SheetBaseBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.headerBar.toolbar.setTitle(R.string.favourites_categories)
binding.buttonDone.isVisible = true
binding.buttonDone.setOnClickListener(this)
val adapter = ShelfCategoriesConfigAdapter(this)
binding.recyclerView.adapter = adapter
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
}
override fun onItemClick(item: FavouriteCategory, view: View) {
viewModel.toggleItem(item)
}
override fun onClick(v: View?) {
dismiss()
}
companion object {
private const val TAG = "ShelfCategoriesConfigSheet"
fun show(fm: FragmentManager) = ShelfCategoriesConfigSheet().show(fm, TAG)
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@HiltViewModel
class ShelfCategoriesConfigViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val content = favouritesRepository.observeCategories()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
fun toggleItem(category: FavouriteCategory) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
favouritesRepository.updateCategory(category.id, !category.isVisibleInLibrary)
}
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
fun shelfCategoryAD(
listener: OnListItemClickListener<FavouriteCategory>,
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(eventListener)
bind {
binding.root.text = item.title
binding.root.isChecked = item.isVisibleInLibrary
}
}

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.shelf.ui.config.size
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
@AndroidEntryPoint
class ShelfSizeBottomSheet :
BaseBottomSheet<SheetShelfSizeBinding>(),
Slider.OnChangeListener,
View.OnClickListener {
@Inject
lateinit var settings: AppSettings
private var labelFormatter: LabelFormatter? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetShelfSizeBinding {
return SheetShelfSizeBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
labelFormatter = IntPercentLabelFormatter(view.context)
binding.sliderGrid.addOnChangeListener(this)
binding.buttonSmall.setOnClickListener(this)
binding.buttonLarge.setOnClickListener(this)
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
}
override fun onDestroyView() {
labelFormatter = null
super.onDestroyView()
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.gridSize = value.toInt()
binding.textViewLabel.text = labelFormatter?.getFormattedValue(value)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_small -> binding.sliderGrid.value -= binding.sliderGrid.stepSize
R.id.button_large -> binding.sliderGrid.value += binding.sliderGrid.stepSize
}
}
companion object {
private const val TAG = "ShelfSizeBottomSheet"
fun show(fm: FragmentManager) = ShelfSizeBottomSheet().show(fm, TAG)
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.shelf.ui.model
import android.content.res.Resources
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
sealed class ShelfSectionModel(
val items: List<MangaItemModel>,
@StringRes val showAllButtonText: Int,
) : ListModel {
abstract val key: Any
abstract fun getTitle(resources: Resources): CharSequence
class History(
items: List<MangaItemModel>,
val timeAgo: DateTimeAgo?,
showAllButtonText: Int,
) : ShelfSectionModel(items, showAllButtonText) {
override val key: Any
get() = timeAgo?.javaClass ?: this::class.java
override fun getTitle(resources: Resources): CharSequence {
return timeAgo?.format(resources) ?: resources.getString(R.string.history)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as History
if (timeAgo != other.timeAgo) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + (timeAgo?.hashCode() ?: 0)
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String {
return "hist_$timeAgo"
}
}
class Favourites(
items: List<MangaItemModel>,
val category: FavouriteCategory,
showAllButtonText: Int,
) : ShelfSectionModel(items, showAllButtonText) {
override val key: Any
get() = category.id
override fun getTitle(resources: Resources): CharSequence {
return category.title
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favourites
if (category != other.category) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + category.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String {
return "fav_${category.id}"
}
}
}