UI with Conductor (#784)
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
|
||||
class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) :
|
||||
DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener {
|
||||
|
||||
private var mangas = emptyList<Manga>()
|
||||
|
||||
private var categories = emptyList<Category>()
|
||||
|
||||
private var preselected = emptyArray<Int>()
|
||||
|
||||
constructor(target: T, mangas: List<Manga>, categories: List<Category>,
|
||||
preselected: Array<Int>) : this() {
|
||||
|
||||
this.mangas = mangas
|
||||
this.categories = categories
|
||||
this.preselected = preselected
|
||||
targetController = target
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
return MaterialDialog.Builder(activity!!)
|
||||
.title(R.string.action_move_category)
|
||||
.items(categories.map { it.name })
|
||||
.itemsCallbackMultiChoice(preselected) { dialog, _, _ ->
|
||||
val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty()
|
||||
(targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories)
|
||||
true
|
||||
}
|
||||
.positiveText(android.R.string.ok)
|
||||
.negativeText(android.R.string.cancel)
|
||||
.build()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.widget.DialogCheckboxView
|
||||
|
||||
class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) :
|
||||
DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener {
|
||||
|
||||
private var mangas = emptyList<Manga>()
|
||||
|
||||
constructor(target: T, mangas: List<Manga>) : this() {
|
||||
this.mangas = mangas
|
||||
targetController = target
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
val view = DialogCheckboxView(activity!!).apply {
|
||||
setDescription(R.string.confirm_delete_manga)
|
||||
setOptionDescription(R.string.also_delete_chapters)
|
||||
}
|
||||
|
||||
return MaterialDialog.Builder(activity!!)
|
||||
.title(R.string.action_remove)
|
||||
.customView(view, true)
|
||||
.positiveText(android.R.string.yes)
|
||||
.negativeText(android.R.string.no)
|
||||
.onPositive { _, _ ->
|
||||
val deleteChapters = view.isChecked()
|
||||
(targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,88 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
|
||||
|
||||
/**
|
||||
* This adapter stores the categories from the library, used with a ViewPager.
|
||||
*
|
||||
* @constructor creates an instance of the adapter.
|
||||
*/
|
||||
class LibraryAdapter(private val fragment: LibraryFragment) : RecyclerViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* The categories to bind in the adapter.
|
||||
*/
|
||||
var categories: List<Category> = emptyList()
|
||||
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
|
||||
set(value) {
|
||||
if (field !== value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view for this adapter.
|
||||
*
|
||||
* @return a new view.
|
||||
*/
|
||||
override fun createView(container: ViewGroup): View {
|
||||
val view = container.inflate(R.layout.item_library_category) as LibraryCategoryView
|
||||
view.onCreate(fragment)
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a view with a position.
|
||||
*
|
||||
* @param view the view to bind.
|
||||
* @param position the position in the adapter.
|
||||
*/
|
||||
override fun bindView(view: View, position: Int) {
|
||||
(view as LibraryCategoryView).onBind(categories[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycles a view.
|
||||
*
|
||||
* @param view the view to recycle.
|
||||
* @param position the position in the adapter.
|
||||
*/
|
||||
override fun recycleView(view: View, position: Int) {
|
||||
(view as LibraryCategoryView).onRecycle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of categories.
|
||||
*
|
||||
* @return the number of categories or 0 if the list is null.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return categories.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the title to display for a category.
|
||||
*
|
||||
* @param position the position of the element.
|
||||
* @return the title to display.
|
||||
*/
|
||||
override fun getPageTitle(position: Int): CharSequence {
|
||||
return categories[position].name
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the view.
|
||||
*/
|
||||
override fun getItemPosition(obj: Any?): Int {
|
||||
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
|
||||
val index = categories.indexOfFirst { it.id == view.category.id }
|
||||
return if (index == -1) POSITION_NONE else index
|
||||
}
|
||||
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
|
||||
|
||||
/**
|
||||
* This adapter stores the categories from the library, used with a ViewPager.
|
||||
*
|
||||
* @constructor creates an instance of the adapter.
|
||||
*/
|
||||
class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* The categories to bind in the adapter.
|
||||
*/
|
||||
var categories: List<Category> = emptyList()
|
||||
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
|
||||
set(value) {
|
||||
if (field !== value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view for this adapter.
|
||||
*
|
||||
* @return a new view.
|
||||
*/
|
||||
override fun createView(container: ViewGroup): View {
|
||||
val view = container.inflate(R.layout.item_library_category2) as LibraryCategoryView
|
||||
view.onCreate(controller)
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a view with a position.
|
||||
*
|
||||
* @param view the view to bind.
|
||||
* @param position the position in the adapter.
|
||||
*/
|
||||
override fun bindView(view: View, position: Int) {
|
||||
(view as LibraryCategoryView).onBind(categories[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycles a view.
|
||||
*
|
||||
* @param view the view to recycle.
|
||||
* @param position the position in the adapter.
|
||||
*/
|
||||
override fun recycleView(view: View, position: Int) {
|
||||
(view as LibraryCategoryView).onRecycle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of categories.
|
||||
*
|
||||
* @return the number of categories or 0 if the list is null.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return categories.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the title to display for a category.
|
||||
*
|
||||
* @param position the position of the element.
|
||||
* @return the title to display.
|
||||
*/
|
||||
override fun getPageTitle(position: Int): CharSequence {
|
||||
return categories[position].name
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the view.
|
||||
*/
|
||||
override fun getItemPosition(obj: Any?): Int {
|
||||
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
|
||||
val index = categories.indexOfFirst { it.id == view.category.id }
|
||||
return if (index == -1) POSITION_NONE else index
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,122 +1,44 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.FrameLayout
|
||||
import eu.davidea.flexibleadapter4.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Adapter storing a list of manga in a certain category.
|
||||
*
|
||||
* @param fragment the fragment containing this adapter.
|
||||
*/
|
||||
class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
|
||||
FlexibleAdapter<LibraryHolder, Manga>() {
|
||||
|
||||
/**
|
||||
* The list of manga in this category.
|
||||
*/
|
||||
private var mangas: List<Manga> = emptyList()
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a list of manga in the adapter.
|
||||
*
|
||||
* @param list the list to set.
|
||||
*/
|
||||
fun setItems(list: List<Manga>) {
|
||||
mItems = list
|
||||
|
||||
// A copy of manga always unfiltered.
|
||||
mangas = ArrayList(list)
|
||||
updateDataSet(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier for a manga.
|
||||
*
|
||||
* @param position the position in the adapter.
|
||||
* @return an identifier for the item.
|
||||
*/
|
||||
override fun getItemId(position: Int): Long {
|
||||
return mItems[position].id!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the list of manga applying [filterObject] for each element.
|
||||
*
|
||||
* @param param the filter. Not used.
|
||||
*/
|
||||
override fun updateDataSet(param: String?) {
|
||||
filterItems(mangas)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a manga depending on a query.
|
||||
*
|
||||
* @param manga the manga to filter.
|
||||
* @param query the query to apply.
|
||||
* @return true if the manga should be included, false otherwise.
|
||||
*/
|
||||
override fun filterObject(manga: Manga, query: String): Boolean = with(manga) {
|
||||
title.toLowerCase().contains(query) ||
|
||||
author != null && author!!.toLowerCase().contains(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view holder.
|
||||
*
|
||||
* @param parent the parent view.
|
||||
* @param viewType the type of the holder.
|
||||
* @return a new view holder for a manga.
|
||||
*/
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryHolder {
|
||||
// Depending on preferences, display a list or display a grid
|
||||
if (parent is AutofitRecyclerView) {
|
||||
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
|
||||
val coverHeight = parent.itemWidth / 3 * 4
|
||||
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
|
||||
gradient.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
|
||||
}
|
||||
return LibraryGridHolder(view, this, fragment)
|
||||
} else {
|
||||
val view = parent.inflate(R.layout.item_catalogue_list)
|
||||
return LibraryListHolder(view, this, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a holder with a new position.
|
||||
*
|
||||
* @param holder the holder to bind.
|
||||
* @param position the position to bind.
|
||||
*/
|
||||
override fun onBindViewHolder(holder: LibraryHolder, position: Int) {
|
||||
val manga = getItem(position)
|
||||
|
||||
holder.onSetValues(manga)
|
||||
// When user scrolls this bind the correct selection status
|
||||
holder.itemView.isActivated = isSelected(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position in the adapter for the given manga.
|
||||
*
|
||||
* @param manga the manga to find.
|
||||
*/
|
||||
fun indexOf(manga: Manga): Int {
|
||||
return mangas.orEmpty().indexOfFirst { it.id == manga.id }
|
||||
}
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
/**
|
||||
* Adapter storing a list of manga in a certain category.
|
||||
*
|
||||
* @param view the fragment containing this adapter.
|
||||
*/
|
||||
class LibraryCategoryAdapter(view: LibraryCategoryView) :
|
||||
FlexibleAdapter<LibraryItem>(null, view, true) {
|
||||
|
||||
/**
|
||||
* The list of manga in this category.
|
||||
*/
|
||||
private var mangas: List<LibraryItem> = emptyList()
|
||||
|
||||
/**
|
||||
* Sets a list of manga in the adapter.
|
||||
*
|
||||
* @param list the list to set.
|
||||
*/
|
||||
fun setItems(list: List<LibraryItem>) {
|
||||
// A copy of manga always unfiltered.
|
||||
mangas = list.toList()
|
||||
|
||||
performFilter()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position in the adapter for the given manga.
|
||||
*
|
||||
* @param manga the manga to find.
|
||||
*/
|
||||
fun indexOf(manga: Manga): Int {
|
||||
return mangas.indexOfFirst { it.manga.id == manga.id }
|
||||
}
|
||||
|
||||
fun performFilter() {
|
||||
updateDataSet(mangas.filter { it.filter(searchText) })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,266 +1,248 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import eu.davidea.flexibleadapter4.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||
import kotlinx.android.synthetic.main.item_library_category.view.*
|
||||
import rx.Subscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Fragment containing the library manga for a certain category.
|
||||
* Uses R.layout.fragment_library_category.
|
||||
*/
|
||||
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
|
||||
: FrameLayout(context, attrs), FlexibleViewHolder.OnListItemClickListener {
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* The fragment containing this view.
|
||||
*/
|
||||
private lateinit var fragment: LibraryFragment
|
||||
|
||||
/**
|
||||
* Category for this view.
|
||||
*/
|
||||
lateinit var category: Category
|
||||
private set
|
||||
|
||||
/**
|
||||
* Recycler view of the list of manga.
|
||||
*/
|
||||
private lateinit var recycler: RecyclerView
|
||||
|
||||
/**
|
||||
* Adapter to hold the manga in this category.
|
||||
*/
|
||||
private lateinit var adapter: LibraryCategoryAdapter
|
||||
|
||||
/**
|
||||
* Subscription for the library manga.
|
||||
*/
|
||||
private var libraryMangaSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription of the library search.
|
||||
*/
|
||||
private var searchSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription of the library selections.
|
||||
*/
|
||||
private var selectionSubscription: Subscription? = null
|
||||
|
||||
fun onCreate(fragment: LibraryFragment) {
|
||||
this.fragment = fragment
|
||||
|
||||
recycler = if (preferences.libraryAsList().getOrDefault()) {
|
||||
(swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
} else {
|
||||
(swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
|
||||
spanCount = fragment.mangaPerRow
|
||||
}
|
||||
}
|
||||
|
||||
adapter = LibraryCategoryAdapter(this)
|
||||
|
||||
recycler.setHasFixedSize(true)
|
||||
recycler.adapter = adapter
|
||||
swipe_refresh.addView(recycler)
|
||||
|
||||
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
|
||||
// Disable swipe refresh when view is not at the top
|
||||
val firstPos = (recycler.layoutManager as LinearLayoutManager)
|
||||
.findFirstCompletelyVisibleItemPosition()
|
||||
swipe_refresh.isEnabled = firstPos == 0
|
||||
}
|
||||
})
|
||||
|
||||
// Double the distance required to trigger sync
|
||||
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
|
||||
swipe_refresh.setOnRefreshListener {
|
||||
if (!LibraryUpdateService.isRunning(context)) {
|
||||
LibraryUpdateService.start(context, category)
|
||||
context.toast(R.string.updating_category)
|
||||
}
|
||||
// It can be a very long operation, so we disable swipe refresh and show a toast.
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onBind(category: Category) {
|
||||
this.category = category
|
||||
|
||||
val presenter = fragment.presenter
|
||||
|
||||
searchSubscription = presenter.searchSubject.subscribe { text ->
|
||||
adapter.searchText = text
|
||||
adapter.updateDataSet()
|
||||
}
|
||||
|
||||
adapter.mode = if (presenter.selectedMangas.isNotEmpty()) {
|
||||
FlexibleAdapter.MODE_MULTI
|
||||
} else {
|
||||
FlexibleAdapter.MODE_SINGLE
|
||||
}
|
||||
|
||||
libraryMangaSubscription = presenter.libraryMangaSubject
|
||||
.subscribe { onNextLibraryManga(it) }
|
||||
|
||||
selectionSubscription = presenter.selectionSubject
|
||||
.subscribe { onSelectionChanged(it) }
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
adapter.setItems(emptyList())
|
||||
adapter.clearSelection()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
searchSubscription?.unsubscribe()
|
||||
libraryMangaSubscription?.unsubscribe()
|
||||
selectionSubscription?.unsubscribe()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
|
||||
* adapter.
|
||||
*
|
||||
* @param event the event received.
|
||||
*/
|
||||
fun onNextLibraryManga(event: LibraryMangaEvent) {
|
||||
// Get the manga list for this category.
|
||||
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
|
||||
|
||||
// Update the category with its manga.
|
||||
adapter.setItems(mangaForCategory)
|
||||
|
||||
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||
fragment.presenter.selectedMangas.forEach { manga ->
|
||||
val position = adapter.indexOf(manga)
|
||||
if (position != -1 && !adapter.isSelected(position)) {
|
||||
adapter.toggleSelection(position)
|
||||
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
|
||||
* depending on the type of event received.
|
||||
*
|
||||
* @param event the selection event received.
|
||||
*/
|
||||
private fun onSelectionChanged(event: LibrarySelectionEvent) {
|
||||
when (event) {
|
||||
is LibrarySelectionEvent.Selected -> {
|
||||
if (adapter.mode != FlexibleAdapter.MODE_MULTI) {
|
||||
adapter.mode = FlexibleAdapter.MODE_MULTI
|
||||
}
|
||||
findAndToggleSelection(event.manga)
|
||||
}
|
||||
is LibrarySelectionEvent.Unselected -> {
|
||||
findAndToggleSelection(event.manga)
|
||||
if (fragment.presenter.selectedMangas.isEmpty()) {
|
||||
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||
}
|
||||
}
|
||||
is LibrarySelectionEvent.Cleared -> {
|
||||
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||
adapter.clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the selection for the given manga and updates the view if needed.
|
||||
*
|
||||
* @param manga the manga to toggle.
|
||||
*/
|
||||
private fun findAndToggleSelection(manga: Manga) {
|
||||
val position = adapter.indexOf(manga)
|
||||
if (position != -1) {
|
||||
adapter.toggleSelection(position)
|
||||
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is clicked.
|
||||
*
|
||||
* @param position the position of the element clicked.
|
||||
* @return true if the item should be selected, false otherwise.
|
||||
*/
|
||||
override fun onListItemClick(position: Int): Boolean {
|
||||
// If the action mode is created and the position is valid, toggle the selection.
|
||||
val item = adapter.getItem(position) ?: return false
|
||||
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||
toggleSelection(position)
|
||||
return true
|
||||
} else {
|
||||
openManga(item)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is long clicked.
|
||||
*
|
||||
* @param position the position of the element clicked.
|
||||
*/
|
||||
override fun onListItemLongClick(position: Int) {
|
||||
fragment.createActionModeIfNeeded()
|
||||
toggleSelection(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a manga.
|
||||
*
|
||||
* @param manga the manga to open.
|
||||
*/
|
||||
private fun openManga(manga: Manga) {
|
||||
// Notify the presenter a manga is being opened.
|
||||
fragment.presenter.onOpenManga()
|
||||
|
||||
// Create a new activity with the manga.
|
||||
val intent = MangaActivity.newIntent(context, manga)
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tells the presenter to toggle the selection for the given position.
|
||||
*
|
||||
* @param position the position to toggle.
|
||||
*/
|
||||
private fun toggleSelection(position: Int) {
|
||||
val manga = adapter.getItem(position) ?: return
|
||||
|
||||
fragment.presenter.setSelection(manga, !adapter.isSelected(position))
|
||||
fragment.invalidateActionMode()
|
||||
}
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||
import kotlinx.android.synthetic.main.item_library_category.view.*
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Fragment containing the library manga for a certain category.
|
||||
* Uses R.layout.fragment_library_category.
|
||||
*/
|
||||
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||
FrameLayout(context, attrs),
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
FlexibleAdapter.OnItemLongClickListener {
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* The fragment containing this view.
|
||||
*/
|
||||
private lateinit var controller: LibraryController
|
||||
|
||||
/**
|
||||
* Category for this view.
|
||||
*/
|
||||
lateinit var category: Category
|
||||
private set
|
||||
|
||||
/**
|
||||
* Recycler view of the list of manga.
|
||||
*/
|
||||
private lateinit var recycler: RecyclerView
|
||||
|
||||
/**
|
||||
* Adapter to hold the manga in this category.
|
||||
*/
|
||||
private lateinit var adapter: LibraryCategoryAdapter
|
||||
|
||||
/**
|
||||
* Subscriptions while the view is bound.
|
||||
*/
|
||||
private var subscriptions = CompositeSubscription()
|
||||
|
||||
fun onCreate(controller: LibraryController) {
|
||||
this.controller = controller
|
||||
|
||||
recycler = if (preferences.libraryAsList().getOrDefault()) {
|
||||
(swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
} else {
|
||||
(swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
|
||||
spanCount = controller.mangaPerRow
|
||||
}
|
||||
}
|
||||
|
||||
adapter = LibraryCategoryAdapter(this)
|
||||
|
||||
recycler.setHasFixedSize(true)
|
||||
recycler.adapter = adapter
|
||||
swipe_refresh.addView(recycler)
|
||||
|
||||
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
|
||||
// Disable swipe refresh when view is not at the top
|
||||
val firstPos = (recycler.layoutManager as LinearLayoutManager)
|
||||
.findFirstCompletelyVisibleItemPosition()
|
||||
swipe_refresh.isEnabled = firstPos == 0
|
||||
}
|
||||
})
|
||||
|
||||
// Double the distance required to trigger sync
|
||||
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
|
||||
swipe_refresh.setOnRefreshListener {
|
||||
if (!LibraryUpdateService.isRunning(context)) {
|
||||
LibraryUpdateService.start(context, category)
|
||||
context.toast(R.string.updating_category)
|
||||
}
|
||||
// It can be a very long operation, so we disable swipe refresh and show a toast.
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onBind(category: Category) {
|
||||
this.category = category
|
||||
|
||||
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
|
||||
FlexibleAdapter.MODE_MULTI
|
||||
} else {
|
||||
FlexibleAdapter.MODE_SINGLE
|
||||
}
|
||||
|
||||
subscriptions += controller.searchRelay
|
||||
.doOnNext { adapter.searchText = it }
|
||||
.skip(1)
|
||||
.subscribe { adapter.performFilter() }
|
||||
|
||||
subscriptions += controller.libraryMangaRelay
|
||||
.subscribe { onNextLibraryManga(it) }
|
||||
|
||||
subscriptions += controller.selectionRelay
|
||||
.subscribe { onSelectionChanged(it) }
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
adapter.setItems(emptyList())
|
||||
adapter.clearSelection()
|
||||
subscriptions.clear()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
subscriptions.clear()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
|
||||
* adapter.
|
||||
*
|
||||
* @param event the event received.
|
||||
*/
|
||||
fun onNextLibraryManga(event: LibraryMangaEvent) {
|
||||
// Get the manga list for this category.
|
||||
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
|
||||
|
||||
// Update the category with its manga.
|
||||
adapter.setItems(mangaForCategory)
|
||||
|
||||
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||
controller.selectedMangas.forEach { manga ->
|
||||
val position = adapter.indexOf(manga)
|
||||
if (position != -1 && !adapter.isSelected(position)) {
|
||||
adapter.toggleSelection(position)
|
||||
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
|
||||
* depending on the type of event received.
|
||||
*
|
||||
* @param event the selection event received.
|
||||
*/
|
||||
private fun onSelectionChanged(event: LibrarySelectionEvent) {
|
||||
when (event) {
|
||||
is LibrarySelectionEvent.Selected -> {
|
||||
if (adapter.mode != FlexibleAdapter.MODE_MULTI) {
|
||||
adapter.mode = FlexibleAdapter.MODE_MULTI
|
||||
}
|
||||
findAndToggleSelection(event.manga)
|
||||
}
|
||||
is LibrarySelectionEvent.Unselected -> {
|
||||
findAndToggleSelection(event.manga)
|
||||
if (controller.selectedMangas.isEmpty()) {
|
||||
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||
}
|
||||
}
|
||||
is LibrarySelectionEvent.Cleared -> {
|
||||
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||
adapter.clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the selection for the given manga and updates the view if needed.
|
||||
*
|
||||
* @param manga the manga to toggle.
|
||||
*/
|
||||
private fun findAndToggleSelection(manga: Manga) {
|
||||
val position = adapter.indexOf(manga)
|
||||
if (position != -1) {
|
||||
adapter.toggleSelection(position)
|
||||
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is clicked.
|
||||
*
|
||||
* @param position the position of the element clicked.
|
||||
* @return true if the item should be selected, false otherwise.
|
||||
*/
|
||||
override fun onItemClick(position: Int): Boolean {
|
||||
// If the action mode is created and the position is valid, toggle the selection.
|
||||
val item = adapter.getItem(position) ?: return false
|
||||
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||
toggleSelection(position)
|
||||
return true
|
||||
} else {
|
||||
openManga(item.manga)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is long clicked.
|
||||
*
|
||||
* @param position the position of the element clicked.
|
||||
*/
|
||||
override fun onItemLongClick(position: Int) {
|
||||
controller.createActionModeIfNeeded()
|
||||
toggleSelection(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a manga.
|
||||
*
|
||||
* @param manga the manga to open.
|
||||
*/
|
||||
private fun openManga(manga: Manga) {
|
||||
controller.openManga(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the presenter to toggle the selection for the given position.
|
||||
*
|
||||
* @param position the position to toggle.
|
||||
*/
|
||||
private fun toggleSelection(position: Int) {
|
||||
val item = adapter.getItem(position) ?: return
|
||||
|
||||
controller.setSelection(item.manga, !adapter.isSelected(position))
|
||||
controller.invalidateActionMode()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.TabLayout
|
||||
import android.support.v4.graphics.drawable.DrawableCompat
|
||||
import android.support.v4.widget.DrawerLayout
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.view.ActionMode
|
||||
import android.support.v7.widget.SearchView
|
||||
import android.view.*
|
||||
import com.bluelinelabs.conductor.ControllerChangeHandler
|
||||
import com.bluelinelabs.conductor.ControllerChangeType
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import com.jakewharton.rxbinding.support.v4.view.pageSelections
|
||||
import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import eu.kanade.tachiyomi.widget.DrawerSwipeCloseListener
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.library_controller.view.*
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class LibraryController(
|
||||
bundle: Bundle? = null,
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) : NucleusController<LibraryPresenter>(bundle),
|
||||
TabbedController,
|
||||
SecondaryDrawerController,
|
||||
ActionMode.Callback,
|
||||
ChangeMangaCategoriesDialog.Listener,
|
||||
DeleteLibraryMangasDialog.Listener {
|
||||
|
||||
/**
|
||||
* Position of the active category.
|
||||
*/
|
||||
var activeCategory: Int = preferences.lastUsedCategory().getOrDefault()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Action mode for selections.
|
||||
*/
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
/**
|
||||
* Library search query.
|
||||
*/
|
||||
private var query = ""
|
||||
|
||||
/**
|
||||
* Currently selected mangas.
|
||||
*/
|
||||
val selectedMangas = mutableListOf<Manga>()
|
||||
|
||||
private var selectedCoverManga: Manga? = null
|
||||
|
||||
/**
|
||||
* Relay to notify the UI of selection updates.
|
||||
*/
|
||||
val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
|
||||
|
||||
/**
|
||||
* Relay to notify search query changes.
|
||||
*/
|
||||
val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
|
||||
|
||||
/**
|
||||
* Relay to notify the library's viewpager for updates.
|
||||
*/
|
||||
val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
|
||||
|
||||
/**
|
||||
* Number of manga per row in grid mode.
|
||||
*/
|
||||
var mangaPerRow = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* TabLayout of the categories.
|
||||
*/
|
||||
private val tabs: TabLayout?
|
||||
get() = activity?.tabs
|
||||
|
||||
private val drawer: DrawerLayout?
|
||||
get() = activity?.drawer
|
||||
|
||||
private var adapter: LibraryAdapter? = null
|
||||
|
||||
/**
|
||||
* Navigation view containing filter/sort/display items.
|
||||
*/
|
||||
private var navView: LibraryNavigationView? = null
|
||||
|
||||
/**
|
||||
* Drawer listener to allow swipe only for closing the drawer.
|
||||
*/
|
||||
private var drawerListener: DrawerLayout.DrawerListener? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun getTitle(): String? {
|
||||
return resources?.getString(R.string.label_library)
|
||||
}
|
||||
|
||||
override fun createPresenter(): LibraryPresenter {
|
||||
return LibraryPresenter()
|
||||
}
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
|
||||
return inflater.inflate(R.layout.library_controller, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedViewState: Bundle?) {
|
||||
super.onViewCreated(view, savedViewState)
|
||||
|
||||
adapter = LibraryAdapter(this)
|
||||
with(view) {
|
||||
view_pager.adapter = adapter
|
||||
view_pager.pageSelections().skip(1).subscribeUntilDestroy {
|
||||
preferences.lastUsedCategory().set(it)
|
||||
activeCategory = it
|
||||
}
|
||||
|
||||
getColumnsPreferenceForCurrentOrientation().asObservable()
|
||||
.doOnNext { mangaPerRow = it }
|
||||
.skip(1)
|
||||
// Set again the adapter to recalculate the covers height
|
||||
.subscribeUntilDestroy { reattachAdapter() }
|
||||
|
||||
if (selectedMangas.isNotEmpty()) {
|
||||
createActionModeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
|
||||
super.onChangeStarted(handler, type)
|
||||
if (type.isEnter) {
|
||||
activity?.tabs?.setupWithViewPager(view?.view_pager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(view: View) {
|
||||
super.onAttach(view)
|
||||
presenter.subscribeLibrary()
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
super.onDestroyView(view)
|
||||
adapter = null
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup {
|
||||
val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
|
||||
drawerListener = DrawerSwipeCloseListener(drawer, view).also {
|
||||
drawer.addDrawerListener(it)
|
||||
}
|
||||
navView = view
|
||||
|
||||
navView?.post {
|
||||
if (isAttached && drawer.isDrawerOpen(navView))
|
||||
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
|
||||
}
|
||||
|
||||
navView?.onGroupClicked = { group ->
|
||||
when (group) {
|
||||
is LibraryNavigationView.FilterGroup -> onFilterChanged()
|
||||
is LibraryNavigationView.SortGroup -> onSortChanged()
|
||||
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun cleanupSecondaryDrawer(drawer: DrawerLayout) {
|
||||
drawerListener?.let { drawer.removeDrawerListener(it) }
|
||||
drawerListener = null
|
||||
navView = null
|
||||
}
|
||||
|
||||
fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<LibraryItem>>) {
|
||||
val view = view ?: return
|
||||
val adapter = adapter ?: return
|
||||
|
||||
// Show empty view if needed
|
||||
if (mangaMap.isNotEmpty()) {
|
||||
view.empty_view.hide()
|
||||
} else {
|
||||
view.empty_view.show(R.drawable.ic_book_black_128dp, R.string.information_empty_library)
|
||||
}
|
||||
|
||||
// Get the current active category.
|
||||
val activeCat = if (adapter.categories.isNotEmpty())
|
||||
view.view_pager.currentItem
|
||||
else
|
||||
activeCategory
|
||||
|
||||
// Set the categories
|
||||
adapter.categories = categories
|
||||
|
||||
// Restore active category.
|
||||
view.view_pager.setCurrentItem(activeCat, false)
|
||||
|
||||
tabs?.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE
|
||||
|
||||
// Delay the scroll position to allow the view to be properly measured.
|
||||
view.post {
|
||||
if (isAttached) {
|
||||
tabs?.setScrollPosition(view.view_pager.currentItem, 0f, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the manga map to child fragments after the adapter is updated.
|
||||
libraryMangaRelay.call(LibraryMangaEvent(mangaMap))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a preference for the number of manga per row based on the current orientation.
|
||||
*
|
||||
* @return the preference.
|
||||
*/
|
||||
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
|
||||
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
preferences.portraitColumns()
|
||||
else
|
||||
preferences.landscapeColumns()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a filter is changed.
|
||||
*/
|
||||
private fun onFilterChanged() {
|
||||
presenter.requestFilterUpdate()
|
||||
(activity as? AppCompatActivity)?.supportInvalidateOptionsMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the sorting mode is changed.
|
||||
*/
|
||||
private fun onSortChanged() {
|
||||
presenter.requestSortUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattaches the adapter to the view pager to recreate fragments
|
||||
*/
|
||||
private fun reattachAdapter() {
|
||||
val pager = view?.view_pager ?: return
|
||||
val adapter = adapter ?: return
|
||||
|
||||
val position = pager.currentItem
|
||||
|
||||
adapter.recycle = false
|
||||
pager.adapter = adapter
|
||||
pager.currentItem = position
|
||||
adapter.recycle = true
|
||||
}
|
||||
|
||||
override fun configureTabs(tabs: TabLayout) {
|
||||
with(tabs) {
|
||||
tabGravity = TabLayout.GRAVITY_CENTER
|
||||
tabMode = TabLayout.MODE_SCROLLABLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the action mode if it's not created already.
|
||||
*/
|
||||
fun createActionModeIfNeeded() {
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the action mode.
|
||||
*/
|
||||
fun destroyActionModeIfNeeded() {
|
||||
actionMode?.finish()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.library, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
|
||||
if (!query.isNullOrEmpty()) {
|
||||
searchItem.expandActionView()
|
||||
searchView.setQuery(query, true)
|
||||
searchView.clearFocus()
|
||||
}
|
||||
|
||||
// Mutate the filter icon because it needs to be tinted and the resource is shared.
|
||||
menu.findItem(R.id.action_filter).icon.mutate()
|
||||
|
||||
searchView.queryTextChanges().subscribeUntilDestroy {
|
||||
query = it.toString()
|
||||
searchRelay.call(query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
val navView = navView ?: return
|
||||
|
||||
val filterItem = menu.findItem(R.id.action_filter)
|
||||
|
||||
// Tint icon if there's a filter active
|
||||
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
|
||||
DrawableCompat.setTint(filterItem.icon, filterColor)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_filter -> {
|
||||
navView?.let { drawer?.openDrawer(Gravity.END) }
|
||||
}
|
||||
R.id.action_update_library -> {
|
||||
activity?.let { LibraryUpdateService.start(it) }
|
||||
}
|
||||
R.id.action_edit_categories -> {
|
||||
router.pushController(RouterTransaction.with(CategoryController())
|
||||
.pushChangeHandler(FadeChangeHandler())
|
||||
.popChangeHandler(FadeChangeHandler()))
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the action mode, forcing it to refresh its content.
|
||||
*/
|
||||
fun invalidateActionMode() {
|
||||
actionMode?.invalidate()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.library_selection, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val count = selectedMangas.size
|
||||
if (count == 0) {
|
||||
// Destroy action mode if there are no items selected.
|
||||
destroyActionModeIfNeeded()
|
||||
} else {
|
||||
mode.title = resources?.getString(R.string.label_selected, count)
|
||||
menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_edit_cover -> {
|
||||
changeSelectedCover()
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
|
||||
R.id.action_delete -> showDeleteMangaDialog()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
// Clear all the manga selections and notify child views.
|
||||
selectedMangas.clear()
|
||||
selectionRelay.call(LibrarySelectionEvent.Cleared())
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
fun openManga(manga: Manga) {
|
||||
// Notify the presenter a manga is being opened.
|
||||
presenter.onOpenManga()
|
||||
|
||||
router.pushController(RouterTransaction.with(MangaController(manga))
|
||||
.pushChangeHandler(FadeChangeHandler())
|
||||
.popChangeHandler(FadeChangeHandler()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selection for a given manga.
|
||||
*
|
||||
* @param manga the manga whose selection has changed.
|
||||
* @param selected whether it's now selected or not.
|
||||
*/
|
||||
fun setSelection(manga: Manga, selected: Boolean) {
|
||||
if (selected) {
|
||||
selectedMangas.add(manga)
|
||||
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
|
||||
} else {
|
||||
selectedMangas.remove(manga)
|
||||
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selected manga to a list of categories.
|
||||
*/
|
||||
private fun showChangeMangaCategoriesDialog() {
|
||||
// Create a copy of selected manga
|
||||
val mangas = selectedMangas.toList()
|
||||
|
||||
// Hide the default category because it has a different behavior than the ones from db.
|
||||
val categories = presenter.categories.filter { it.id != 0 }
|
||||
|
||||
// Get indexes of the common categories to preselect.
|
||||
val commonCategoriesIndexes = presenter.getCommonCategories(mangas)
|
||||
.map { categories.indexOf(it) }
|
||||
.toTypedArray()
|
||||
|
||||
ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes)
|
||||
.showDialog(router, null)
|
||||
}
|
||||
|
||||
private fun showDeleteMangaDialog() {
|
||||
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router, null)
|
||||
}
|
||||
|
||||
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
|
||||
presenter.moveMangasToCategories(categories, mangas)
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
|
||||
override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
|
||||
presenter.removeMangaFromLibrary(mangas, deleteChapters)
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the cover for the selected manga.
|
||||
*
|
||||
* @param mangas a list of selected manga.
|
||||
*/
|
||||
private fun changeSelectedCover() {
|
||||
val manga = selectedMangas.firstOrNull() ?: return
|
||||
selectedCoverManga = manga
|
||||
|
||||
if (manga.favorite) {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.type = "image/*"
|
||||
startActivityForResult(Intent.createChooser(intent,
|
||||
resources?.getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN)
|
||||
} else {
|
||||
activity?.toast(R.string.notification_first_add_to_library)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_IMAGE_OPEN) {
|
||||
if (data == null || resultCode != Activity.RESULT_OK) return
|
||||
val activity = activity ?: return
|
||||
val manga = selectedCoverManga ?: return
|
||||
|
||||
try {
|
||||
// Get the file's input stream from the incoming Intent
|
||||
activity.contentResolver.openInputStream(data.data).use {
|
||||
// Update cover to selected file, show error if something went wrong
|
||||
if (presenter.editCoverWithStream(it, manga)) {
|
||||
// TODO refresh cover
|
||||
} else {
|
||||
activity.toast(R.string.notification_cover_update_failed)
|
||||
}
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
activity.toast(R.string.notification_cover_update_failed)
|
||||
Timber.e(error)
|
||||
}
|
||||
selectedCoverManga = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Key to change the cover of a manga in [onActivityResult].
|
||||
*/
|
||||
const val REQUEST_IMAGE_OPEN = 101
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.TabLayout
|
||||
import android.support.v4.graphics.drawable.DrawableCompat
|
||||
import android.support.v4.view.ViewPager
|
||||
import android.support.v4.widget.DrawerLayout
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.view.ActionMode
|
||||
import android.support.v7.widget.SearchView
|
||||
import android.view.*
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryActivity
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import eu.kanade.tachiyomi.widget.DialogCheckboxView
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.fragment_library.*
|
||||
import nucleus.factory.RequiresPresenter
|
||||
import rx.Subscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Fragment that shows the manga from the library.
|
||||
* Uses R.layout.fragment_library.
|
||||
*/
|
||||
@RequiresPresenter(LibraryPresenter::class)
|
||||
class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback {
|
||||
|
||||
/**
|
||||
* Adapter containing the categories of the library.
|
||||
*/
|
||||
lateinit var adapter: LibraryAdapter
|
||||
private set
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* TabLayout of the categories.
|
||||
*/
|
||||
private val tabs: TabLayout
|
||||
get() = (activity as MainActivity).tabs
|
||||
|
||||
/**
|
||||
* Position of the active category.
|
||||
*/
|
||||
private var activeCategory: Int = 0
|
||||
|
||||
/**
|
||||
* Query of the search box.
|
||||
*/
|
||||
private var query: String? = null
|
||||
|
||||
/**
|
||||
* Action mode for manga selection.
|
||||
*/
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
/**
|
||||
* Selected manga for editing its cover.
|
||||
*/
|
||||
private var selectedCoverManga: Manga? = null
|
||||
|
||||
/**
|
||||
* Number of manga per row in grid mode.
|
||||
*/
|
||||
var mangaPerRow = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* Navigation view containing filter/sort/display items.
|
||||
*/
|
||||
private lateinit var navView: LibraryNavigationView
|
||||
|
||||
/**
|
||||
* Drawer listener to allow swipe only for closing the drawer.
|
||||
*/
|
||||
private val drawerListener by lazy {
|
||||
object : DrawerLayout.SimpleDrawerListener() {
|
||||
override fun onDrawerClosed(drawerView: View) {
|
||||
if (drawerView == navView) {
|
||||
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDrawerOpened(drawerView: View) {
|
||||
if (drawerView == navView) {
|
||||
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription for the number of manga per row.
|
||||
*/
|
||||
private var numColumnsSubscription: Subscription? = null
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Key to change the cover of a manga in [onActivityResult].
|
||||
*/
|
||||
const val REQUEST_IMAGE_OPEN = 101
|
||||
|
||||
/**
|
||||
* Key to save and restore [query] from a [Bundle].
|
||||
*/
|
||||
const val QUERY_KEY = "query_key"
|
||||
|
||||
/**
|
||||
* Key to save and restore [activeCategory] from a [Bundle].
|
||||
*/
|
||||
const val CATEGORY_KEY = "category_key"
|
||||
|
||||
/**
|
||||
* Creates a new instance of this fragment.
|
||||
*
|
||||
* @return a new instance of [LibraryFragment].
|
||||
*/
|
||||
fun newInstance(): LibraryFragment {
|
||||
return LibraryFragment()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_library, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
setToolbarTitle(getString(R.string.label_library))
|
||||
|
||||
adapter = LibraryAdapter(this)
|
||||
view_pager.adapter = adapter
|
||||
view_pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
preferences.lastUsedCategory().set(position)
|
||||
}
|
||||
})
|
||||
tabs.setupWithViewPager(view_pager)
|
||||
|
||||
if (savedState != null) {
|
||||
activeCategory = savedState.getInt(CATEGORY_KEY)
|
||||
query = savedState.getString(QUERY_KEY)
|
||||
presenter.searchSubject.call(query)
|
||||
if (presenter.selectedMangas.isNotEmpty()) {
|
||||
createActionModeIfNeeded()
|
||||
}
|
||||
} else {
|
||||
activeCategory = preferences.lastUsedCategory().getOrDefault()
|
||||
}
|
||||
|
||||
numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable()
|
||||
.doOnNext { mangaPerRow = it }
|
||||
.skip(1)
|
||||
// Set again the adapter to recalculate the covers height
|
||||
.subscribe { reattachAdapter() }
|
||||
|
||||
|
||||
// Inflate and prepare drawer
|
||||
navView = activity.drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
|
||||
activity.drawer.addView(navView)
|
||||
activity.drawer.addDrawerListener(drawerListener)
|
||||
|
||||
navView.post {
|
||||
if (isAdded && !activity.drawer.isDrawerOpen(navView))
|
||||
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
|
||||
}
|
||||
|
||||
navView.onGroupClicked = { group ->
|
||||
when (group) {
|
||||
is LibraryNavigationView.FilterGroup -> onFilterChanged()
|
||||
is LibraryNavigationView.SortGroup -> onSortChanged()
|
||||
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
presenter.subscribeLibrary()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
activity.drawer.removeDrawerListener(drawerListener)
|
||||
activity.drawer.removeView(navView)
|
||||
numColumnsSubscription?.unsubscribe()
|
||||
tabs.setupWithViewPager(null)
|
||||
tabs.visibility = View.GONE
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putInt(CATEGORY_KEY, view_pager.currentItem)
|
||||
outState.putString(QUERY_KEY, query)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.library, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
|
||||
if (!query.isNullOrEmpty()) {
|
||||
searchItem.expandActionView()
|
||||
searchView.setQuery(query, true)
|
||||
searchView.clearFocus()
|
||||
}
|
||||
|
||||
// Mutate the filter icon because it needs to be tinted and the resource is shared.
|
||||
menu.findItem(R.id.action_filter).icon.mutate()
|
||||
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
onSearchTextChange(query)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean {
|
||||
onSearchTextChange(newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
val filterItem = menu.findItem(R.id.action_filter)
|
||||
|
||||
// Tint icon if there's a filter active
|
||||
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
|
||||
DrawableCompat.setTint(filterItem.icon, filterColor)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_filter -> {
|
||||
activity.drawer.openDrawer(Gravity.END)
|
||||
}
|
||||
R.id.action_update_library -> {
|
||||
LibraryUpdateService.start(activity)
|
||||
}
|
||||
R.id.action_edit_categories -> {
|
||||
val intent = CategoryActivity.newIntent(activity)
|
||||
startActivity(intent)
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a filter is changed.
|
||||
*/
|
||||
private fun onFilterChanged() {
|
||||
presenter.requestFilterUpdate()
|
||||
activity.supportInvalidateOptionsMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the sorting mode is changed.
|
||||
*/
|
||||
private fun onSortChanged() {
|
||||
presenter.requestSortUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattaches the adapter to the view pager to recreate fragments
|
||||
*/
|
||||
private fun reattachAdapter() {
|
||||
val position = view_pager.currentItem
|
||||
adapter.recycle = false
|
||||
view_pager.adapter = adapter
|
||||
view_pager.currentItem = position
|
||||
adapter.recycle = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a preference for the number of manga per row based on the current orientation.
|
||||
*
|
||||
* @return the preference.
|
||||
*/
|
||||
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
|
||||
return if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
preferences.portraitColumns()
|
||||
else
|
||||
preferences.landscapeColumns()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the query.
|
||||
*
|
||||
* @param query the new value of the query.
|
||||
*/
|
||||
private fun onSearchTextChange(query: String?) {
|
||||
this.query = query
|
||||
|
||||
// Notify the subject the query has changed.
|
||||
if (isResumed) {
|
||||
presenter.searchSubject.call(query)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the library is updated. It sets the new data and updates the view.
|
||||
*
|
||||
* @param categories the categories of the library.
|
||||
* @param mangaMap a map containing the manga for each category.
|
||||
*/
|
||||
fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<Manga>>) {
|
||||
// Check if library is empty and update information accordingly.
|
||||
(activity as MainActivity).updateEmptyView(mangaMap.isEmpty(),
|
||||
R.string.information_empty_library, R.drawable.ic_book_black_128dp)
|
||||
|
||||
// Get the current active category.
|
||||
val activeCat = if (adapter.categories.isNotEmpty()) view_pager.currentItem else activeCategory
|
||||
|
||||
// Set the categories
|
||||
adapter.categories = categories
|
||||
tabs.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE
|
||||
|
||||
// Restore active category.
|
||||
view_pager.setCurrentItem(activeCat, false)
|
||||
// Delay the scroll position to allow the view to be properly measured.
|
||||
view_pager.post { if (isAdded) tabs.setScrollPosition(view_pager.currentItem, 0f, true) }
|
||||
|
||||
// Send the manga map to child fragments after the adapter is updated.
|
||||
presenter.libraryMangaSubject.call(LibraryMangaEvent(mangaMap))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the action mode if it's not created already.
|
||||
*/
|
||||
fun createActionModeIfNeeded() {
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the action mode.
|
||||
*/
|
||||
fun destroyActionModeIfNeeded() {
|
||||
actionMode?.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the action mode, forcing it to refresh its content.
|
||||
*/
|
||||
fun invalidateActionMode() {
|
||||
actionMode?.invalidate()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.library_selection, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val count = presenter.selectedMangas.size
|
||||
if (count == 0) {
|
||||
// Destroy action mode if there are no items selected.
|
||||
destroyActionModeIfNeeded()
|
||||
} else {
|
||||
mode.title = getString(R.string.label_selected, count)
|
||||
menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_edit_cover -> {
|
||||
changeSelectedCover(presenter.selectedMangas)
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
R.id.action_move_to_category -> moveMangasToCategories(presenter.selectedMangas)
|
||||
R.id.action_delete -> showDeleteMangaDialog()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
presenter.clearSelections()
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the cover for the selected manga.
|
||||
*
|
||||
* @param mangas a list of selected manga.
|
||||
*/
|
||||
private fun changeSelectedCover(mangas: List<Manga>) {
|
||||
if (mangas.size == 1) {
|
||||
selectedCoverManga = mangas[0]
|
||||
if (selectedCoverManga?.favorite ?: false) {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.type = "image/*"
|
||||
startActivityForResult(Intent.createChooser(intent,
|
||||
getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN)
|
||||
} else {
|
||||
context.toast(R.string.notification_first_add_to_library)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMAGE_OPEN) {
|
||||
selectedCoverManga?.let { manga ->
|
||||
|
||||
try {
|
||||
// Get the file's input stream from the incoming Intent
|
||||
context.contentResolver.openInputStream(data.data).use {
|
||||
// Update cover to selected file, show error if something went wrong
|
||||
if (presenter.editCoverWithStream(it, manga)) {
|
||||
// TODO refresh cover
|
||||
} else {
|
||||
context.toast(R.string.notification_cover_update_failed)
|
||||
}
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
context.toast(R.string.notification_cover_update_failed)
|
||||
Timber.e(error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selected manga to a list of categories.
|
||||
*
|
||||
* @param mangas the manga list to move.
|
||||
*/
|
||||
private fun moveMangasToCategories(mangas: List<Manga>) {
|
||||
// Hide the default category because it has a different behavior than the ones from db.
|
||||
val categories = presenter.categories.filter { it.id != 0 }
|
||||
|
||||
// Get indexes of the common categories to preselect.
|
||||
val commonCategoriesIndexes = presenter.getCommonCategories(mangas)
|
||||
.map { categories.indexOf(it) }
|
||||
.toTypedArray()
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.title(R.string.action_move_category)
|
||||
.items(categories.map { it.name })
|
||||
.itemsCallbackMultiChoice(commonCategoriesIndexes) { dialog, positions, text ->
|
||||
val selectedCategories = positions.map { categories[it] }
|
||||
presenter.moveMangasToCategories(selectedCategories, mangas)
|
||||
destroyActionModeIfNeeded()
|
||||
true
|
||||
}
|
||||
.positiveText(android.R.string.ok)
|
||||
.negativeText(android.R.string.cancel)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showDeleteMangaDialog() {
|
||||
val view = DialogCheckboxView(context).apply {
|
||||
setDescription(R.string.confirm_delete_manga)
|
||||
setOptionDescription(R.string.also_delete_chapters)
|
||||
}
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.title(R.string.action_remove)
|
||||
.customView(view, true)
|
||||
.positiveText(android.R.string.yes)
|
||||
.negativeText(android.R.string.no)
|
||||
.onPositive { dialog, action ->
|
||||
val deleteChapters = view.isChecked()
|
||||
presenter.removeMangaFromLibrary(deleteChapters)
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,49 +1,49 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||
|
||||
/**
|
||||
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||
* All the elements from the layout file "item_catalogue_grid" are available in this class.
|
||||
*
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to single tap and long tap events.
|
||||
* @constructor creates a new library holder.
|
||||
*/
|
||||
class LibraryGridHolder(private val view: View,
|
||||
private val adapter: LibraryCategoryAdapter,
|
||||
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||
: LibraryHolder(view, adapter, listener) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
override fun onSetValues(manga: Manga) {
|
||||
// Update the title of the manga.
|
||||
view.title.text = manga.title
|
||||
|
||||
// Update the unread count and its visibility.
|
||||
with(view.unread_text) {
|
||||
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||
text = manga.unread.toString()
|
||||
}
|
||||
|
||||
// Update the cover.
|
||||
Glide.clear(view.thumbnail)
|
||||
Glide.with(view.context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||
.centerCrop()
|
||||
.into(view.thumbnail)
|
||||
}
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||
|
||||
/**
|
||||
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||
* All the elements from the layout file "item_catalogue_grid" are available in this class.
|
||||
*
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to single tap and long tap events.
|
||||
* @constructor creates a new library holder.
|
||||
*/
|
||||
class LibraryGridHolder(
|
||||
private val view: View,
|
||||
private val adapter: FlexibleAdapter<*>
|
||||
) : LibraryHolder(view, adapter) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
override fun onSetValues(manga: Manga) {
|
||||
// Update the title of the manga.
|
||||
view.title.text = manga.title
|
||||
|
||||
// Update the unread count and its visibility.
|
||||
with(view.unread_text) {
|
||||
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||
text = manga.unread.toString()
|
||||
}
|
||||
|
||||
// Update the cover.
|
||||
Glide.clear(view.thumbnail)
|
||||
Glide.with(view.context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||
.centerCrop()
|
||||
.into(view.thumbnail)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||
|
||||
/**
|
||||
* Generic class used to hold the displayed data of a manga in the library.
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to the single tap and long tap events.
|
||||
*/
|
||||
|
||||
abstract class LibraryHolder(private val view: View,
|
||||
adapter: LibraryCategoryAdapter,
|
||||
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||
: FlexibleViewHolder(view, adapter, listener) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
abstract fun onSetValues(manga: Manga)
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
/**
|
||||
* Generic class used to hold the displayed data of a manga in the library.
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to the single tap and long tap events.
|
||||
*/
|
||||
|
||||
abstract class LibraryHolder(
|
||||
view: View,
|
||||
adapter: FlexibleAdapter<*>
|
||||
) : FlexibleViewHolder(view, adapter) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
abstract fun onSetValues(manga: Manga)
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.FrameLayout
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFilterable
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||
|
||||
class LibraryItem(val manga: Manga) : AbstractFlexibleItem<LibraryHolder>(), IFilterable {
|
||||
|
||||
override fun getLayoutRes(): Int {
|
||||
return R.layout.item_catalogue_grid
|
||||
}
|
||||
|
||||
override fun createViewHolder(adapter: FlexibleAdapter<*>,
|
||||
inflater: LayoutInflater,
|
||||
parent: ViewGroup): LibraryHolder {
|
||||
|
||||
return if (parent is AutofitRecyclerView) {
|
||||
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
|
||||
val coverHeight = parent.itemWidth / 3 * 4
|
||||
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
|
||||
gradient.layoutParams = FrameLayout.LayoutParams(
|
||||
MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
|
||||
}
|
||||
LibraryGridHolder(view, adapter)
|
||||
} else {
|
||||
val view = parent.inflate(R.layout.item_catalogue_list)
|
||||
LibraryListHolder(view, adapter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
|
||||
holder: LibraryHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?) {
|
||||
|
||||
holder.onSetValues(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a manga depending on a query.
|
||||
*
|
||||
* @param constraint the query to apply.
|
||||
* @return true if the manga should be included, false otherwise.
|
||||
*/
|
||||
override fun filter(constraint: String): Boolean {
|
||||
return manga.title.contains(constraint, true) ||
|
||||
(manga.author?.contains(constraint, true) ?: false)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is LibraryItem) {
|
||||
return manga.id == other.manga.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return manga.id!!.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,57 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||
import kotlinx.android.synthetic.main.item_catalogue_list.view.*
|
||||
|
||||
/**
|
||||
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||
* All the elements from the layout file "item_library_list" are available in this class.
|
||||
*
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to single tap and long tap events.
|
||||
* @constructor creates a new library holder.
|
||||
*/
|
||||
|
||||
class LibraryListHolder(private val view: View,
|
||||
private val adapter: LibraryCategoryAdapter,
|
||||
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||
: LibraryHolder(view, adapter, listener) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
override fun onSetValues(manga: Manga) {
|
||||
// Update the title of the manga.
|
||||
itemView.title.text = manga.title
|
||||
|
||||
// Update the unread count and its visibility.
|
||||
with(itemView.unread_text) {
|
||||
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||
text = manga.unread.toString()
|
||||
}
|
||||
|
||||
// Create thumbnail onclick to simulate long click
|
||||
itemView.thumbnail.setOnClickListener {
|
||||
// Simulate long click on this view to enter selection mode
|
||||
onLongClick(itemView)
|
||||
}
|
||||
|
||||
// Update the cover.
|
||||
Glide.clear(itemView.thumbnail)
|
||||
Glide.with(itemView.context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||
.centerCrop()
|
||||
.dontAnimate()
|
||||
.into(itemView.thumbnail)
|
||||
}
|
||||
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.view.View
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import kotlinx.android.synthetic.main.item_catalogue_list.view.*
|
||||
|
||||
/**
|
||||
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||
* All the elements from the layout file "item_library_list" are available in this class.
|
||||
*
|
||||
* @param view the inflated view for this holder.
|
||||
* @param adapter the adapter handling this holder.
|
||||
* @param listener a listener to react to single tap and long tap events.
|
||||
* @constructor creates a new library holder.
|
||||
*/
|
||||
|
||||
class LibraryListHolder(
|
||||
private val view: View,
|
||||
private val adapter: FlexibleAdapter<*>
|
||||
) : LibraryHolder(view, adapter) {
|
||||
|
||||
/**
|
||||
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||
* holder with the given manga.
|
||||
*
|
||||
* @param manga the manga to bind.
|
||||
*/
|
||||
override fun onSetValues(manga: Manga) {
|
||||
// Update the title of the manga.
|
||||
itemView.title.text = manga.title
|
||||
|
||||
// Update the unread count and its visibility.
|
||||
with(itemView.unread_text) {
|
||||
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||
text = manga.unread.toString()
|
||||
}
|
||||
|
||||
// Create thumbnail onclick to simulate long click
|
||||
itemView.thumbnail.setOnClickListener {
|
||||
// Simulate long click on this view to enter selection mode
|
||||
onLongClick(itemView)
|
||||
}
|
||||
|
||||
// Update the cover.
|
||||
Glide.clear(itemView.thumbnail)
|
||||
Glide.with(itemView.context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||
.centerCrop()
|
||||
.dontAnimate()
|
||||
.into(itemView.thumbnail)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
class LibraryMangaEvent(val mangas: Map<Int, List<Manga>>) {
|
||||
class LibraryMangaEvent(val mangas: Map<Int, List<LibraryItem>>) {
|
||||
|
||||
fun getMangaForCategory(category: Category): List<Manga>? {
|
||||
fun getMangaForCategory(category: Category): List<LibraryItem>? {
|
||||
return mangas[category.id]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,373 +1,315 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Pair
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.combineLatest
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Presenter of [LibraryFragment].
|
||||
*/
|
||||
class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
||||
|
||||
/**
|
||||
* Database.
|
||||
*/
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Cover cache.
|
||||
*/
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Download manager.
|
||||
*/
|
||||
private val downloadManager: DownloadManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Categories of the library.
|
||||
*/
|
||||
var categories: List<Category> = emptyList()
|
||||
|
||||
/**
|
||||
* Currently selected manga.
|
||||
*/
|
||||
val selectedMangas = mutableListOf<Manga>()
|
||||
|
||||
/**
|
||||
* Search query of the library.
|
||||
*/
|
||||
val searchSubject: BehaviorRelay<String> = BehaviorRelay.create()
|
||||
|
||||
/**
|
||||
* Subject to notify the library's viewpager for updates.
|
||||
*/
|
||||
val libraryMangaSubject: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
|
||||
|
||||
/**
|
||||
* Subject to notify the UI of selection updates.
|
||||
*/
|
||||
val selectionSubject: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
|
||||
|
||||
/**
|
||||
* Relay used to apply the UI filters to the last emission of the library.
|
||||
*/
|
||||
private val filterTriggerRelay = BehaviorRelay.create(Unit)
|
||||
|
||||
/**
|
||||
* Relay used to apply the selected sorting method to the last emission of the library.
|
||||
*/
|
||||
private val sortTriggerRelay = BehaviorRelay.create(Unit)
|
||||
|
||||
/**
|
||||
* Library subscription.
|
||||
*/
|
||||
private var librarySubscription: Subscription? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
subscribeLibrary()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to library if needed.
|
||||
*/
|
||||
fun subscribeLibrary() {
|
||||
if (librarySubscription.isNullOrUnsubscribed()) {
|
||||
librarySubscription = getLibraryObservable()
|
||||
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
|
||||
{ lib, tick -> Pair(lib.first, applyFilters(lib.second)) })
|
||||
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
|
||||
{ lib, tick -> Pair(lib.first, applySort(lib.second)) })
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeLatestCache({ view, pair ->
|
||||
view.onNextLibraryUpdate(pair.first, pair.second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies library filters to the given map of manga.
|
||||
*
|
||||
* @param map the map to filter.
|
||||
*/
|
||||
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
|
||||
// Cached list of downloaded manga directories given a source id.
|
||||
val mangaDirsForSource = mutableMapOf<Long, Map<String?, UniFile>>()
|
||||
|
||||
// Cached list of downloaded chapter directories for a manga.
|
||||
val chapterDirectories = mutableMapOf<Long, Boolean>()
|
||||
|
||||
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
|
||||
|
||||
val filterUnread = preferences.filterUnread().getOrDefault()
|
||||
|
||||
val filterFn: (Manga) -> Boolean = f@ { manga ->
|
||||
// Filter out manga without source.
|
||||
val source = sourceManager.get(manga.source) ?: return@f false
|
||||
|
||||
// Filter when there isn't unread chapters.
|
||||
if (filterUnread && manga.unread == 0) {
|
||||
return@f false
|
||||
}
|
||||
|
||||
// Filter when the download directory doesn't exist or is null.
|
||||
if (filterDownloaded) {
|
||||
// Get the directories for the source of the manga.
|
||||
val dirsForSource = mangaDirsForSource.getOrPut(source.id) {
|
||||
val sourceDir = downloadManager.findSourceDir(source)
|
||||
sourceDir?.listFiles()?.associateBy { it.name }.orEmpty()
|
||||
}
|
||||
|
||||
val mangaDirName = downloadManager.getMangaDirName(manga)
|
||||
val mangaDir = dirsForSource[mangaDirName] ?: return@f false
|
||||
|
||||
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
|
||||
mangaDir.listFiles()?.isNotEmpty() ?: false
|
||||
}
|
||||
if (!hasDirs) {
|
||||
return@f false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
return map.mapValues { entry -> entry.value.filter(filterFn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies library sorting to the given map of manga.
|
||||
*
|
||||
* @param map the map to sort.
|
||||
*/
|
||||
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
|
||||
val sortingMode = preferences.librarySortingMode().getOrDefault()
|
||||
|
||||
val lastReadManga by lazy {
|
||||
var counter = 0
|
||||
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
|
||||
}
|
||||
|
||||
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
|
||||
when (sortingMode) {
|
||||
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
|
||||
LibrarySort.LAST_READ -> {
|
||||
// Get index of manga, set equal to list if size unknown.
|
||||
val manga1LastRead = lastReadManga[manga1.id!!] ?: lastReadManga.size
|
||||
val manga2LastRead = lastReadManga[manga2.id!!] ?: lastReadManga.size
|
||||
manga1LastRead.compareTo(manga2LastRead)
|
||||
}
|
||||
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
|
||||
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
|
||||
else -> throw Exception("Unknown sorting mode")
|
||||
}
|
||||
}
|
||||
|
||||
val comparator = if (preferences.librarySortingAscending().getOrDefault())
|
||||
Comparator(sortFn)
|
||||
else
|
||||
Collections.reverseOrder(sortFn)
|
||||
|
||||
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the categories and all its manga from the database.
|
||||
*
|
||||
* @return an observable of the categories and its manga.
|
||||
*/
|
||||
private fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> {
|
||||
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
|
||||
{ dbCategories, libraryManga ->
|
||||
val categories = if (libraryManga.containsKey(0))
|
||||
arrayListOf(Category.createDefault()) + dbCategories
|
||||
else
|
||||
dbCategories
|
||||
|
||||
this.categories = categories
|
||||
Pair(categories, libraryManga)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the categories from the database.
|
||||
*
|
||||
* @return an observable of the categories.
|
||||
*/
|
||||
private fun getCategoriesObservable(): Observable<List<Category>> {
|
||||
return db.getCategories().asRxObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the manga grouped by categories.
|
||||
*
|
||||
* @return an observable containing a map with the category id as key and a list of manga as the
|
||||
* value.
|
||||
*/
|
||||
private fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> {
|
||||
return db.getLibraryMangas().asRxObservable()
|
||||
.map { list -> list.groupBy { it.category } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the library to be filtered.
|
||||
*/
|
||||
fun requestFilterUpdate() {
|
||||
filterTriggerRelay.call(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the library to be sorted.
|
||||
*/
|
||||
fun requestSortUpdate() {
|
||||
sortTriggerRelay.call(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is opened.
|
||||
*/
|
||||
fun onOpenManga() {
|
||||
// Avoid further db updates for the library when it's not needed
|
||||
librarySubscription?.let { remove(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selection for a given manga.
|
||||
*
|
||||
* @param manga the manga whose selection has changed.
|
||||
* @param selected whether it's now selected or not.
|
||||
*/
|
||||
fun setSelection(manga: Manga, selected: Boolean) {
|
||||
if (selected) {
|
||||
selectedMangas.add(manga)
|
||||
selectionSubject.call(LibrarySelectionEvent.Selected(manga))
|
||||
} else {
|
||||
selectedMangas.remove(manga)
|
||||
selectionSubject.call(LibrarySelectionEvent.Unselected(manga))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all the manga selections and notifies the UI.
|
||||
*/
|
||||
fun clearSelections() {
|
||||
selectedMangas.clear()
|
||||
selectionSubject.call(LibrarySelectionEvent.Cleared())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common categories for the given list of manga.
|
||||
*
|
||||
* @param mangas the list of manga.
|
||||
*/
|
||||
fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
|
||||
if (mangas.isEmpty()) return emptyList()
|
||||
return mangas.toSet()
|
||||
.map { db.getCategoriesForManga(it).executeAsBlocking() }
|
||||
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the selected manga from the library.
|
||||
*
|
||||
* @param deleteChapters whether to also delete downloaded chapters.
|
||||
*/
|
||||
fun removeMangaFromLibrary(deleteChapters: Boolean) {
|
||||
// Create a set of the list
|
||||
val mangaToDelete = selectedMangas.distinctBy { it.id }
|
||||
mangaToDelete.forEach { it.favorite = false }
|
||||
|
||||
Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() }
|
||||
.onErrorResumeNext { Observable.empty() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
|
||||
Observable.fromCallable {
|
||||
mangaToDelete.forEach { manga ->
|
||||
coverCache.deleteFromCache(manga.thumbnail_url)
|
||||
if (deleteChapters) {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
if (source != null) {
|
||||
downloadManager.findMangaDir(source, manga)?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given list of manga to categories.
|
||||
*
|
||||
* @param categories the selected categories.
|
||||
* @param mangas the list of manga to move.
|
||||
*/
|
||||
fun moveMangasToCategories(categories: List<Category>, mangas: List<Manga>) {
|
||||
val mc = ArrayList<MangaCategory>()
|
||||
|
||||
for (manga in mangas) {
|
||||
for (cat in categories) {
|
||||
mc.add(MangaCategory.create(manga, cat))
|
||||
}
|
||||
}
|
||||
|
||||
db.setMangaCategories(mc, mangas)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cover with local file.
|
||||
*
|
||||
* @param inputStream the new cover.
|
||||
* @param manga the manga edited.
|
||||
* @return true if the cover is updated, false otherwise
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
LocalSource.updateCover(context, manga, inputStream)
|
||||
return true
|
||||
}
|
||||
|
||||
if (manga.thumbnail_url != null && manga.favorite) {
|
||||
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Pair
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.combineLatest
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Presenter of [LibraryController].
|
||||
*/
|
||||
class LibraryPresenter(
|
||||
private val db: DatabaseHelper = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get(),
|
||||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get()
|
||||
) : BasePresenter<LibraryController>() {
|
||||
|
||||
private val context = preferences.context
|
||||
|
||||
/**
|
||||
* Categories of the library.
|
||||
*/
|
||||
var categories: List<Category> = emptyList()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Relay used to apply the UI filters to the last emission of the library.
|
||||
*/
|
||||
private val filterTriggerRelay = BehaviorRelay.create(Unit)
|
||||
|
||||
/**
|
||||
* Relay used to apply the selected sorting method to the last emission of the library.
|
||||
*/
|
||||
private val sortTriggerRelay = BehaviorRelay.create(Unit)
|
||||
|
||||
/**
|
||||
* Library subscription.
|
||||
*/
|
||||
private var librarySubscription: Subscription? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
subscribeLibrary()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to library if needed.
|
||||
*/
|
||||
fun subscribeLibrary() {
|
||||
if (librarySubscription.isNullOrUnsubscribed()) {
|
||||
librarySubscription = getLibraryObservable()
|
||||
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
|
||||
{ lib, _ -> Pair(lib.first, applyFilters(lib.second)) })
|
||||
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
|
||||
{ lib, _ -> Pair(lib.first, applySort(lib.second)) })
|
||||
.map { Pair(it.first, it.second.mapValues { it.value.map(::LibraryItem) }) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeLatestCache({ view, pair ->
|
||||
view.onNextLibraryUpdate(pair.first, pair.second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies library filters to the given map of manga.
|
||||
*
|
||||
* @param map the map to filter.
|
||||
*/
|
||||
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
|
||||
// Cached list of downloaded manga directories given a source id.
|
||||
val mangaDirsForSource = mutableMapOf<Long, Map<String?, UniFile>>()
|
||||
|
||||
// Cached list of downloaded chapter directories for a manga.
|
||||
val chapterDirectories = mutableMapOf<Long, Boolean>()
|
||||
|
||||
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
|
||||
|
||||
val filterUnread = preferences.filterUnread().getOrDefault()
|
||||
|
||||
val filterFn: (Manga) -> Boolean = f@ { manga ->
|
||||
// Filter out manga without source.
|
||||
val source = sourceManager.get(manga.source) ?: return@f false
|
||||
|
||||
// Filter when there isn't unread chapters.
|
||||
if (filterUnread && manga.unread == 0) {
|
||||
return@f false
|
||||
}
|
||||
|
||||
// Filter when the download directory doesn't exist or is null.
|
||||
if (filterDownloaded) {
|
||||
// Get the directories for the source of the manga.
|
||||
val dirsForSource = mangaDirsForSource.getOrPut(source.id) {
|
||||
val sourceDir = downloadManager.findSourceDir(source)
|
||||
sourceDir?.listFiles()?.associateBy { it.name }.orEmpty()
|
||||
}
|
||||
|
||||
val mangaDirName = downloadManager.getMangaDirName(manga)
|
||||
val mangaDir = dirsForSource[mangaDirName] ?: return@f false
|
||||
|
||||
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
|
||||
mangaDir.listFiles()?.isNotEmpty() ?: false
|
||||
}
|
||||
if (!hasDirs) {
|
||||
return@f false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
return map.mapValues { entry -> entry.value.filter(filterFn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies library sorting to the given map of manga.
|
||||
*
|
||||
* @param map the map to sort.
|
||||
*/
|
||||
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
|
||||
val sortingMode = preferences.librarySortingMode().getOrDefault()
|
||||
|
||||
val lastReadManga by lazy {
|
||||
var counter = 0
|
||||
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
|
||||
}
|
||||
|
||||
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
|
||||
when (sortingMode) {
|
||||
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
|
||||
LibrarySort.LAST_READ -> {
|
||||
// Get index of manga, set equal to list if size unknown.
|
||||
val manga1LastRead = lastReadManga[manga1.id!!] ?: lastReadManga.size
|
||||
val manga2LastRead = lastReadManga[manga2.id!!] ?: lastReadManga.size
|
||||
manga1LastRead.compareTo(manga2LastRead)
|
||||
}
|
||||
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
|
||||
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
|
||||
else -> throw Exception("Unknown sorting mode")
|
||||
}
|
||||
}
|
||||
|
||||
val comparator = if (preferences.librarySortingAscending().getOrDefault())
|
||||
Comparator(sortFn)
|
||||
else
|
||||
Collections.reverseOrder(sortFn)
|
||||
|
||||
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the categories and all its manga from the database.
|
||||
*
|
||||
* @return an observable of the categories and its manga.
|
||||
*/
|
||||
private fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> {
|
||||
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
|
||||
{ dbCategories, libraryManga ->
|
||||
val categories = if (libraryManga.containsKey(0))
|
||||
arrayListOf(Category.createDefault()) + dbCategories
|
||||
else
|
||||
dbCategories
|
||||
|
||||
this.categories = categories
|
||||
Pair(categories, libraryManga)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the categories from the database.
|
||||
*
|
||||
* @return an observable of the categories.
|
||||
*/
|
||||
private fun getCategoriesObservable(): Observable<List<Category>> {
|
||||
return db.getCategories().asRxObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the manga grouped by categories.
|
||||
*
|
||||
* @return an observable containing a map with the category id as key and a list of manga as the
|
||||
* value.
|
||||
*/
|
||||
private fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> {
|
||||
return db.getLibraryMangas().asRxObservable()
|
||||
.map { list -> list.groupBy { it.category } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the library to be filtered.
|
||||
*/
|
||||
fun requestFilterUpdate() {
|
||||
filterTriggerRelay.call(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the library to be sorted.
|
||||
*/
|
||||
fun requestSortUpdate() {
|
||||
sortTriggerRelay.call(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a manga is opened.
|
||||
*/
|
||||
fun onOpenManga() {
|
||||
// Avoid further db updates for the library when it's not needed
|
||||
librarySubscription?.let { remove(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common categories for the given list of manga.
|
||||
*
|
||||
* @param mangas the list of manga.
|
||||
*/
|
||||
fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
|
||||
if (mangas.isEmpty()) return emptyList()
|
||||
return mangas.toSet()
|
||||
.map { db.getCategoriesForManga(it).executeAsBlocking() }
|
||||
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the selected manga from the library.
|
||||
*
|
||||
* @param mangas the list of manga to delete.
|
||||
* @param deleteChapters whether to also delete downloaded chapters.
|
||||
*/
|
||||
fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
|
||||
// Create a set of the list
|
||||
val mangaToDelete = mangas.distinctBy { it.id }
|
||||
mangaToDelete.forEach { it.favorite = false }
|
||||
|
||||
Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() }
|
||||
.onErrorResumeNext { Observable.empty() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
|
||||
Observable.fromCallable {
|
||||
mangaToDelete.forEach { manga ->
|
||||
coverCache.deleteFromCache(manga.thumbnail_url)
|
||||
if (deleteChapters) {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
if (source != null) {
|
||||
downloadManager.findMangaDir(source, manga)?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given list of manga to categories.
|
||||
*
|
||||
* @param categories the selected categories.
|
||||
* @param mangas the list of manga to move.
|
||||
*/
|
||||
fun moveMangasToCategories(categories: List<Category>, mangas: List<Manga>) {
|
||||
val mc = ArrayList<MangaCategory>()
|
||||
|
||||
for (manga in mangas) {
|
||||
for (cat in categories) {
|
||||
mc.add(MangaCategory.create(manga, cat))
|
||||
}
|
||||
}
|
||||
|
||||
db.setMangaCategories(mc, mangas)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cover with local file.
|
||||
*
|
||||
* @param inputStream the new cover.
|
||||
* @param manga the manga edited.
|
||||
* @return true if the cover is updated, false otherwise
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
LocalSource.updateCover(context, manga, inputStream)
|
||||
return true
|
||||
}
|
||||
|
||||
if (manga.thumbnail_url != null && manga.favorite) {
|
||||
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user