Add library fragment
parent
0eff85dca3
commit
ebdc2dfb0e
@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.library
|
||||
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.library.ui.LibraryViewModel
|
||||
|
||||
val libraryModule
|
||||
get() = module {
|
||||
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
package org.koitharu.kotatsu.library.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.FragmentLibraryBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.library.ui.adapter.LibraryAdapter
|
||||
import org.koitharu.kotatsu.library.ui.model.LibraryGroupModel
|
||||
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.findViewsByType
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class LibraryFragment : BaseFragment<FragmentLibraryBinding>(), MangaListListener, ActionMode.Callback {
|
||||
|
||||
private val viewModel by viewModel<LibraryViewModel>()
|
||||
private var adapter: LibraryAdapter? = null
|
||||
private var selectionDecoration: MangaSelectionDecoration? = null
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentLibraryBinding {
|
||||
return FragmentLibraryBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val sizeResolver = ItemSizeResolver(resources, get())
|
||||
val itemCLickListener = object : OnListItemClickListener<LibraryGroupModel> {
|
||||
override fun onItemClick(item: LibraryGroupModel, view: View) {
|
||||
|
||||
}
|
||||
}
|
||||
selectionDecoration = MangaSelectionDecoration(view.context)
|
||||
adapter = LibraryAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
coil = get(),
|
||||
listener = this,
|
||||
itemClickListener = itemCLickListener,
|
||||
sizeResolver = sizeResolver,
|
||||
selectionDecoration = checkNotNull(selectionDecoration),
|
||||
)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
adapter = null
|
||||
selectionDecoration = null
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Manga, view: View) {
|
||||
if (selectionDecoration?.checkedItemsCount != 0) {
|
||||
selectionDecoration?.toggleItemChecked(item.id)
|
||||
if (selectionDecoration?.checkedItemsCount == 0) {
|
||||
actionMode?.finish()
|
||||
} else {
|
||||
actionMode?.invalidate()
|
||||
invalidateItemDecorations()
|
||||
}
|
||||
return
|
||||
}
|
||||
val intent = DetailsActivity.newIntent(view.context, item)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Manga, view: View): Boolean {
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||
}
|
||||
return actionMode?.also {
|
||||
selectionDecoration?.setItemIsChecked(item.id, true)
|
||||
invalidateItemDecorations()
|
||||
it.invalidate()
|
||||
} != null
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
|
||||
override fun onTagRemoveClick(tag: MangaTag) = Unit
|
||||
|
||||
override fun onFilterClick() = Unit
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.recyclerView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
top = insets.top,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.title = selectionDecoration?.checkedItemsCount?.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
val ctx = context ?: return false
|
||||
return when (item.itemId) {
|
||||
R.id.action_share -> {
|
||||
ShareHelper(ctx).shareMangaLinks(collectSelectedItems())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_favourite -> {
|
||||
FavouriteCategoriesBottomSheet.show(childFragmentManager, collectSelectedItems())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
DownloadService.confirmAndStart(ctx, collectSelectedItems())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
selectionDecoration?.clearSelection()
|
||||
invalidateItemDecorations()
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
private fun collectSelectedItems(): Set<Manga> {
|
||||
val ids = selectionDecoration?.checkedItemsIds
|
||||
if (ids.isNullOrEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
return emptySet()//viewModel.getItems(ids)
|
||||
}
|
||||
|
||||
private fun invalidateItemDecorations() {
|
||||
binding.recyclerView.findViewsByType(RecyclerView::class.java).forEach {
|
||||
it.invalidateItemDecorations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onListChanged(list: List<ListModel>) {
|
||||
adapter?.items = list
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
Snackbar.make(
|
||||
binding.recyclerView,
|
||||
e.getDisplayMessage(resources),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = LibraryFragment()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package org.koitharu.kotatsu.library.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
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.library.ui.model.LibraryGroupModel
|
||||
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.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
|
||||
class LibraryViewModel(
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
private val shortcutsRepository: ShortcutsRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel(), ListExtraProvider {
|
||||
|
||||
val content: LiveData<List<ListModel>> = combine(
|
||||
historyRepository.observeAllWithHistory(),
|
||||
favouritesRepository.observeAllGrouped(SortOrder.NEWEST),
|
||||
) { history, favourites ->
|
||||
mapList(history, favourites)
|
||||
}.catch { e ->
|
||||
e.toErrorState(canRetry = false)
|
||||
}.asLiveDataDistinct(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
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
result += LibraryGroupModel.History(mapHistory(history), null)
|
||||
}
|
||||
for ((category, list) in favourites) {
|
||||
result += LibraryGroupModel.Favourites(list.toUi(ListMode.GRID, this), category)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun mapHistory(list: List<MangaWithHistory>): List<MangaItemModel> {
|
||||
val showPercent = settings.isReadingIndicatorsEnabled
|
||||
val result = ArrayList<MangaItemModel>(list.size)
|
||||
for ((manga, history) in list) {
|
||||
val counter = trackingRepository.getNewChaptersCount(manga.id)
|
||||
val percent = if (showPercent) history.percent else PROGRESS_NONE
|
||||
result += manga.toGridModel(counter, percent)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.library.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.library.ui.model.LibraryGroupModel
|
||||
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.*
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class LibraryAdapter(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
coil: ImageLoader,
|
||||
listener: MangaListListener,
|
||||
sizeResolver: ItemSizeResolver,
|
||||
selectionDecoration: MangaSelectionDecoration,
|
||||
itemClickListener: OnListItemClickListener<LibraryGroupModel>,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
val pool = RecyclerView.RecycledViewPool()
|
||||
delegatesManager
|
||||
.addDelegate(
|
||||
libraryGroupAD(
|
||||
sharedPool = pool,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
coil = coil,
|
||||
sizeResolver = sizeResolver,
|
||||
selectionDecoration = selectionDecoration,
|
||||
listener = listener,
|
||||
itemClickListener = itemClickListener,
|
||||
)
|
||||
)
|
||||
.addDelegate(loadingStateAD())
|
||||
.addDelegate(loadingFooterAD())
|
||||
.addDelegate(emptyStateListAD(listener))
|
||||
.addDelegate(errorStateListAD(listener))
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return when {
|
||||
oldItem is LibraryGroupModel && newItem is LibraryGroupModel -> {
|
||||
oldItem.key == newItem.key
|
||||
}
|
||||
else -> oldItem.javaClass == newItem.javaClass
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return Intrinsics.areEqual(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.library.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.databinding.ItemListGroupBinding
|
||||
import org.koitharu.kotatsu.library.ui.model.LibraryGroupModel
|
||||
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
fun libraryGroupAD(
|
||||
sharedPool: RecyclerView.RecycledViewPool,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
coil: ImageLoader,
|
||||
sizeResolver: ItemSizeResolver,
|
||||
selectionDecoration: MangaSelectionDecoration,
|
||||
listener: OnListItemClickListener<Manga>,
|
||||
itemClickListener: OnListItemClickListener<LibraryGroupModel>,
|
||||
) = adapterDelegateViewBinding<LibraryGroupModel, ListModel, ItemListGroupBinding>(
|
||||
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.recyclerView.setRecycledViewPool(sharedPool)
|
||||
val adapter = ListDelegationAdapter(
|
||||
mangaGridItemAD(coil, lifecycleOwner, listener, sizeResolver)
|
||||
)
|
||||
binding.recyclerView.addItemDecoration(selectionDecoration)
|
||||
binding.recyclerView.adapter = adapter
|
||||
val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing)
|
||||
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.getTitle(context.resources)
|
||||
adapter.items = item.items
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package org.koitharu.kotatsu.library.ui.model
|
||||
|
||||
import android.content.res.Resources
|
||||
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 LibraryGroupModel(
|
||||
val items: List<MangaItemModel>
|
||||
) : ListModel {
|
||||
|
||||
abstract val key: Any
|
||||
abstract fun getTitle(resources: Resources): CharSequence
|
||||
|
||||
class History(
|
||||
items: List<MangaItemModel>,
|
||||
val timeAgo: DateTimeAgo?,
|
||||
) : LibraryGroupModel(items) {
|
||||
|
||||
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 (items != other.items) return false
|
||||
if (timeAgo != other.timeAgo) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = items.hashCode()
|
||||
result = 31 * result + (timeAgo?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Favourites(
|
||||
items: List<MangaItemModel>,
|
||||
val category: FavouriteCategory,
|
||||
) : LibraryGroupModel(items) {
|
||||
|
||||
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 (items != other.items) return false
|
||||
if (category != other.category) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = items.hashCode()
|
||||
result = 31 * result + category.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.search.ui.multi.adapter
|
||||
package org.koitharu.kotatsu.list.ui
|
||||
|
||||
import android.content.res.Resources
|
||||
import kotlin.math.roundToInt
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ItemSizeResolver(resources: Resources, settings: AppSettings) {
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="@dimen/list_spacing"
|
||||
android:paddingTop="@dimen/grid_spacing_outer"
|
||||
android:paddingRight="@dimen/list_spacing"
|
||||
android:paddingBottom="@dimen/grid_spacing_outer"
|
||||
app:fastScrollEnabled="true"
|
||||
app:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager" />
|
||||
Loading…
Reference in New Issue