Move tracking to manga info actions

Currently just opens a separate view. To be iterated upon later.
This commit is contained in:
arkon 2020-07-11 12:13:05 -04:00
parent fa5d2276c0
commit 23fe848a35
9 changed files with 902 additions and 955 deletions

View file

@ -75,7 +75,6 @@ open class GlobalSearchController(
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
router.pushController(MangaController(manga, true).withFadeTransaction())
}

View file

@ -1,37 +1,84 @@
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.support.RouterPagerAdapter
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxrelay.BehaviorRelay
import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.PagerControllerBinding
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ChaptersControllerBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.manga.chapter.MangaInfoChaptersController
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterHolder
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersAdapter
import eu.kanade.tachiyomi.ui.manga.chapter.DeleteChaptersDialog
import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomChaptersDialog
import eu.kanade.tachiyomi.ui.manga.chapter.MangaChaptersHeaderAdapter
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoHeaderAdapter
import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.android.synthetic.main.main_activity.tabs
import rx.Subscription
import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class MangaController : RxController<PagerControllerBinding>, TabbedController {
class MangaController :
NucleusController<ChaptersControllerBinding, MangaPresenter>,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
constructor(manga: Manga?, fromSource: Boolean = false) : super(
Bundle().apply {
@ -58,20 +105,48 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
var source: Source? = null
private set
private var adapter: MangaDetailAdapter? = null
private val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false)
val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false)
private val preferences: PreferencesHelper by injectLazy()
private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create()
private var mangaInfoAdapter: MangaInfoHeaderAdapter? = null
private var chaptersHeaderAdapter: MangaChaptersHeaderAdapter? = null
private var chaptersAdapter: ChaptersAdapter? = null
private var trackingIconSubscription: Subscription? = null
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedChapters = mutableSetOf<ChapterItem>()
private val isLocalSource by lazy { presenter.source.id == LocalSource.ID }
private var lastClickPosition = -1
private var isRefreshingInfo = false
private var isRefreshingChapters = false
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return manga?.title
}
override fun createPresenter(): MangaPresenter {
return MangaPresenter(
manga!!,
source!!
)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = PagerControllerBinding.inflate(inflater)
binding = ChaptersControllerBinding.inflate(inflater)
return binding.root
}
@ -80,23 +155,65 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
if (manga == null || source == null) return
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
// Init RecyclerView and adapter
mangaInfoAdapter =
MangaInfoHeaderAdapter(
this,
fromSource
)
chaptersHeaderAdapter =
MangaChaptersHeaderAdapter()
chaptersAdapter = ChaptersAdapter(
this,
view.context
)
adapter = MangaDetailAdapter()
binding.pager.adapter = adapter
}
binding.recycler.adapter = ConcatAdapter(mangaInfoAdapter, chaptersHeaderAdapter, chaptersAdapter)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recycler.setHasFixedSize(true)
chaptersAdapter?.fastScroller = binding.fastScroller
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
activity?.tabs?.setupWithViewPager(binding.pager)
trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) }
// Skips directly to chapters list if navigated to from the library
binding.recycler.post {
if (!fromSource && preferences.jumpToChapters()) {
(binding.recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(1, 0)
}
}
binding.swipeRefresh.refreshes()
.onEach {
fetchMangaInfoFromSource(manualFetch = true)
fetchChaptersFromSource(manualFetch = true)
}
.launchIn(scope)
binding.fab.clicks()
.onEach {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = binding.fab.getCoordinates()
if (!binding.revealView.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else {
view.context.toast(R.string.no_next_chapter)
}
}
.launchIn(scope)
binding.fab.shrinkOnScroll(binding.recycler)
binding.actionToolbar.offsetAppbarHeight(activity!!)
binding.fab.offsetAppbarHeight(activity!!)
}
override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) {
@ -107,68 +224,704 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
}
}
override fun configureTabs(tabs: TabLayout) {
with(tabs) {
tabGravity = TabLayout.GRAVITY_FILL
tabMode = TabLayout.MODE_FIXED
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
binding.actionToolbar.destroy()
mangaInfoAdapter = null
chaptersHeaderAdapter = null
chaptersAdapter = null
super.onDestroyView(view)
}
override fun onActivityResumed(activity: Activity) {
if (view == null) return
// Check if animation view is visible
if (binding.revealView.visibility == View.VISIBLE) {
// Show the unreveal effect
val coordinates = binding.fab.getCoordinates()
binding.revealView.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
super.onActivityResumed(activity)
}
override fun cleanupTabs(tabs: TabLayout) {
trackingIconSubscription?.unsubscribe()
setTrackingIconInternal(false)
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.chapters, menu)
}
fun setTrackingIcon(visible: Boolean) {
trackingIconRelay.call(visible)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
val menuFilterEmpty = menu.findItem(R.id.action_filter_empty)
private fun setTrackingIconInternal(visible: Boolean) {
val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return
val drawable = if (visible) {
VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterDownloaded.isEnabled = !presenter.forceDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
val filterSet = presenter.onlyRead() || presenter.onlyUnread() || presenter.onlyDownloaded() || presenter.onlyBookmarked()
if (filterSet) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
DrawableCompat.setTint(menu.findItem(R.id.action_filter).icon, filterColor)
}
// Only show remove filter option if there's a filter set.
menuFilterEmpty.isVisible = filterSet
// Display mode submenu
if (presenter.manga.displayMode == Manga.DISPLAY_NAME) {
menu.findItem(R.id.display_title).isChecked = true
} else {
null
menu.findItem(R.id.display_chapter_number).isChecked = true
}
tab.icon = drawable
// Sorting mode submenu
val sortingItem = when (presenter.manga.sorting) {
Manga.SORTING_SOURCE -> R.id.sort_by_source
Manga.SORTING_NUMBER -> R.id.sort_by_number
Manga.SORTING_UPLOAD_DATE -> R.id.sort_by_upload_date
else -> throw NotImplementedError("Unimplemented sorting method")
}
menu.findItem(sortingItem).isChecked = true
menu.findItem(R.id.action_sort_descending).isChecked = presenter.manga.sortDescending()
// Hide download options for local manga
menu.findItem(R.id.download_group).isVisible = !isLocalSource
}
private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.display_title -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NAME)
}
R.id.display_chapter_number -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NUMBER)
}
private val tabTitles = listOf(
R.string.manga_chapters_tab,
R.string.manga_tracking_tab
)
.map { resources!!.getString(it) }
R.id.sort_by_source -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_SOURCE)
}
R.id.sort_by_number -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_NUMBER)
}
R.id.sort_by_upload_date -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_UPLOAD_DATE)
}
R.id.action_sort_descending -> {
presenter.reverseSortOrder()
activity?.invalidateOptionsMenu()
}
private val tabCount = tabTitles.size - if (Injekt.get<TrackManager>().hasLoggedServices()) 0 else 1
R.id.download_next, R.id.download_next_5, R.id.download_next_10,
R.id.download_custom, R.id.download_unread, R.id.download_all
-> downloadChapters(item.itemId)
override fun getCount(): Int {
return tabCount
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_migrate -> migrateManga()
}
return super.onOptionsItemSelected(item)
}
private fun updateRefreshing() {
binding.swipeRefresh.isRefreshing = isRefreshingInfo || isRefreshingChapters
}
// Manga info - start
/**
* Check if manga is initialized.
* If true update header with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextMangaInfo(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
mangaInfoAdapter?.update(manga, source)
} else {
// Initialize manga.
fetchMangaInfoFromSource()
}
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaInfoFromSource(manualFetch: Boolean = false) {
isRefreshingInfo = true
updateRefreshing()
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource(manualFetch)
}
fun onFetchMangaInfoDone() {
isRefreshingInfo = false
updateRefreshing()
}
fun onFetchMangaInfoError(error: Throwable) {
isRefreshingInfo = false
updateRefreshing()
activity?.toast(error.message)
}
fun openMangaInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url.toString()
} catch (e: Exception) {
return
}
override fun configureRouter(router: Router, position: Int) {
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CHAPTERS_CONTROLLER -> MangaInfoChaptersController(fromSource)
TRACK_CONTROLLER -> TrackController()
else -> error("Wrong position $position")
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.manga.title)
startActivity(intent)
}
fun shareManga() {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url.toString()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
fun onFavoriteClick() {
val manga = presenter.manga
if (manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_removed_library))
} else {
addToLibrary(manga)
}
}
fun onTrackingClick() {
router.pushController(TrackController(manga).withFadeTransaction())
}
private fun addToLibrary(manga: Manga) {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
// Default category set
defaultCategory != null -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, defaultCategory)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, null)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
private fun toggleFavorite() {
val view = view
val isNowFavorite = presenter.toggleFavorite()
if (view != null && !isNowFavorite && presenter.hasDownloads()) {
view.snack(view.context.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
router.setRoot(RouterTransaction.with(controller))
}
}
override fun getPageTitle(position: Int): CharSequence {
return tabTitles[position]
mangaInfoAdapter?.notifyDataSetChanged()
}
fun onCategoriesClick() {
val manga = presenter.manga
val categories = presenter.getCategories()
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
if (!manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_added_library))
}
presenter.moveMangaToCategories(manga, categories)
}
/**
* Perform a global search using the provided query.
*
* @param query the search query to pass to the search controller
*/
fun performGlobalSearch(query: String) {
router.pushController(GlobalSearchController(query).withFadeTransaction())
}
/**
* Perform a search using the provided query.
*
* @param query the search query to the parent controller
*/
fun performSearch(query: String) {
if (router.backstackSize < 2) {
return
}
when (val previousController = router.backstack[router.backstackSize - 2].controller()) {
is LibraryController -> {
router.handleBack()
previousController.search(query)
}
is UpdatesController,
is HistoryController -> {
// Manually navigate to LibraryController
router.handleBack()
(router.activity as MainActivity).setSelectedNavItem(R.id.nav_library)
val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController
controller.search(query)
}
is BrowseSourceController -> {
router.handleBack()
previousController.searchWithQuery(query)
}
}
}
// Manga info - end
// Chapters list - start
/**
* Initiates source migration for the specific manga.
*/
private fun migrateManga() {
val controller =
SearchController(
presenter.manga
)
controller.targetController = this
router.pushController(controller.withFadeTransaction())
}
fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty and it hasn't requested previously, fetch chapters from source
// We use presenter chapters instead because they are always unfiltered
if (!presenter.hasRequested && presenter.chapters.isEmpty()) {
fetchChaptersFromSource()
}
val chaptersHeader = chaptersHeaderAdapter ?: return
chaptersHeader.setNumChapters(chapters.size)
val adapter = chaptersAdapter ?: return
adapter.updateDataSet(chapters)
if (selectedChapters.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedChapters.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
val context = view?.context
if (context != null && chapters.any { it.read }) {
binding.fab.text = context.getString(R.string.action_resume)
}
}
private fun fetchChaptersFromSource(manualFetch: Boolean = false) {
isRefreshingChapters = true
updateRefreshing()
presenter.fetchChaptersFromSource(manualFetch)
}
fun onFetchChaptersDone() {
isRefreshingChapters = false
updateRefreshing()
}
fun onFetchChaptersError(error: Throwable) {
isRefreshingChapters = false
updateRefreshing()
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = chaptersAdapter ?: return false
val item = adapter.getItem(position) ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position
toggleSelection(position)
true
} else {
openChapter(item.chapter)
false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position ->
for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position ->
for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
lastClickPosition = position
chaptersAdapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedChapters.add(item)
} else {
selectedChapters.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedChapters.add(item)
actionMode?.invalidate()
}
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = chaptersAdapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
binding.actionToolbar.show(
actionMode!!,
R.menu.chapter_selection
) { onActionItemClicked(it!!) }
}
}
private fun destroyActionModeIfNeeded() {
lastClickPosition = -1
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
chaptersAdapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = chaptersAdapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = count.toString()
val chapters = getSelectedChapters()
binding.actionToolbar.findItem(R.id.action_download)?.isVisible = !isLocalSource && chapters.any { !it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = !isLocalSource && chapters.any { it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
// Hide FAB to avoid interfering with the bottom action toolbar
// binding.fab.hide()
binding.fab.gone()
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return onActionItemClicked(item)
}
private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_select_inverse -> selectInverse()
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true)
R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false)
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters())
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
binding.actionToolbar.hide()
chaptersAdapter?.mode = SelectableAdapter.Mode.SINGLE
chaptersAdapter?.clearSelection()
selectedChapters.clear()
actionMode = null
// TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton]
// fails to show up properly
// binding.fab.show()
binding.fab.visible()
}
override fun onDetach(view: View) {
destroyActionModeIfNeeded()
super.onDetach(view)
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = chaptersAdapter ?: return
adapter.selectAll()
selectedChapters.addAll(adapter.items)
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = chaptersAdapter ?: return
selectedChapters.clear()
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
selectedChapters.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) })
actionMode?.invalidate()
adapter.notifyDataSetChanged()
}
private fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
destroyActionModeIfNeeded()
}
private fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
destroyActionModeIfNeeded()
}
private fun downloadChapters(chapters: List<ChapterItem>) {
val view = view
val manga = presenter.manga
presenter.downloadChapters(chapters)
if (view != null && !manga.favorite) {
binding.recycler.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
addToLibrary(manga)
}
}
}
destroyActionModeIfNeeded()
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapters: List<ChapterItem>) {
val adapter = chaptersAdapter ?: return
val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = prevChapters.indexOf(chapters.last())
if (chapterPos != -1) {
markAsRead(prevChapters.take(chapterPos))
}
destroyActionModeIfNeeded()
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
fun deleteChapters(chapters: List<ChapterItem>) {
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
destroyActionModeIfNeeded()
}
fun onChaptersDeleted(chapters: List<ChapterItem>) {
// this is needed so the downloaded text gets removed from the item
chapters.forEach {
chaptersAdapter?.updateItem(it)
}
chaptersAdapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
Timber.e(error)
}
// OVERFLOW MENU DIALOGS
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
chaptersAdapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
private fun downloadChapters(choice: Int) {
val chaptersToDownload = when (choice) {
R.id.download_next -> getUnreadChaptersSorted().take(1)
R.id.download_next_5 -> getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> getUnreadChaptersSorted().take(10)
R.id.download_custom -> {
showCustomDownloadDialog()
return
}
R.id.download_unread -> presenter.chapters.filter { !it.read }
R.id.download_all -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
destroyActionModeIfNeeded()
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(
this,
presenter.chapters.size
).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
// Chapters list - end
companion object {
const val FROM_SOURCE_EXTRA = "from_source"
const val MANGA_EXTRA = "manga"
const val INFO_CHAPTERS_CONTROLLER = 0
const val TRACK_CONTROLLER = 1
}
}

View file

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.ui.manga.chapter
package eu.kanade.tachiyomi.ui.manga
import android.os.Bundle
import com.jakewharton.rxrelay.PublishRelay
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
@ -29,14 +30,14 @@ import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaInfoChaptersPresenter(
class MangaPresenter(
val manga: Manga,
val source: Source,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get()
) : BasePresenter<MangaInfoChaptersController>() {
) : BasePresenter<MangaController>() {
/**
* Subscription to update the manga from the source.
@ -83,7 +84,7 @@ class MangaInfoChaptersPresenter(
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoChaptersController::onNextChapters) { _, error -> Timber.e(error) }
.subscribeLatestCache(MangaController::onNextChapters) { _, error -> Timber.e(error) }
// Manga info - end
@ -139,7 +140,7 @@ class MangaInfoChaptersPresenter(
{ view, _ ->
view.onFetchMangaInfoDone()
},
MangaInfoChaptersController::onFetchMangaInfoError
MangaController::onFetchMangaInfoError
)
}
@ -226,7 +227,7 @@ class MangaInfoChaptersPresenter(
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(MangaInfoChaptersController::onChapterStatusChange) { _, error ->
.subscribeLatestCache(MangaController::onChapterStatusChange) { _, error ->
Timber.e(error)
}
}
@ -279,7 +280,7 @@ class MangaInfoChaptersPresenter(
{ view, _ ->
view.onFetchChaptersDone()
},
MangaInfoChaptersController::onFetchChaptersError
MangaController::onFetchChaptersError
)
}
@ -413,7 +414,7 @@ class MangaInfoChaptersPresenter(
{ view, _ ->
view.onChaptersDeleted(chapters)
},
MangaInfoChaptersController::onChaptersDeletedError
MangaController::onChaptersDeletedError
)
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import java.text.DateFormat
import java.text.DecimalFormat
@ -11,7 +12,7 @@ import java.text.DecimalFormatSymbols
import uy.kohesive.injekt.injectLazy
class ChaptersAdapter(
controller: MangaInfoChaptersController,
controller: MangaController,
context: Context
) : FlexibleAdapter<ChapterItem>(null, controller, true) {

View file

@ -1,859 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ChaptersControllerBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class MangaInfoChaptersController(private val fromSource: Boolean = false) :
NucleusController<ChaptersControllerBinding, MangaInfoChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
private val preferences: PreferencesHelper by injectLazy()
private var mangaInfoAdapter: MangaInfoHeaderAdapter? = null
private var chaptersHeaderAdapter: MangaChaptersHeaderAdapter? = null
private var chaptersAdapter: ChaptersAdapter? = null
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedChapters = mutableSetOf<ChapterItem>()
private val isLocalSource by lazy { presenter.source.id == LocalSource.ID }
private var lastClickPosition = -1
private var isRefreshingInfo = false
private var isRefreshingChapters = false
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): MangaInfoChaptersPresenter {
val ctrl = parentController as MangaController
return MangaInfoChaptersPresenter(
ctrl.manga!!, ctrl.source!!
)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = ChaptersControllerBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
val ctrl = parentController as MangaController
if (ctrl.manga == null || ctrl.source == null) return
// Init RecyclerView and adapter
mangaInfoAdapter = MangaInfoHeaderAdapter(this, fromSource)
chaptersHeaderAdapter = MangaChaptersHeaderAdapter()
chaptersAdapter = ChaptersAdapter(this, view.context)
binding.recycler.adapter = ConcatAdapter(mangaInfoAdapter, chaptersHeaderAdapter, chaptersAdapter)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recycler.setHasFixedSize(true)
chaptersAdapter?.fastScroller = binding.fastScroller
// Skips directly to chapters list if navigated to from the library
binding.recycler.post {
if (!fromSource && preferences.jumpToChapters()) {
(binding.recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(1, 0)
}
}
binding.swipeRefresh.refreshes()
.onEach {
fetchMangaInfoFromSource(manualFetch = true)
fetchChaptersFromSource(manualFetch = true)
}
.launchIn(scope)
binding.fab.clicks()
.onEach {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = binding.fab.getCoordinates()
if (!binding.revealView.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else {
view.context.toast(R.string.no_next_chapter)
}
}
.launchIn(scope)
binding.fab.shrinkOnScroll(binding.recycler)
binding.actionToolbar.offsetAppbarHeight(activity!!)
binding.fab.offsetAppbarHeight(activity!!)
}
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
binding.actionToolbar.destroy()
mangaInfoAdapter = null
chaptersHeaderAdapter = null
chaptersAdapter = null
super.onDestroyView(view)
}
override fun onActivityResumed(activity: Activity) {
if (view == null) return
// Check if animation view is visible
if (binding.revealView.visibility == View.VISIBLE) {
// Show the unreveal effect
val coordinates = binding.fab.getCoordinates()
binding.revealView.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
super.onActivityResumed(activity)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.chapters, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
val menuFilterEmpty = menu.findItem(R.id.action_filter_empty)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterDownloaded.isEnabled = !presenter.forceDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
val filterSet = presenter.onlyRead() || presenter.onlyUnread() || presenter.onlyDownloaded() || presenter.onlyBookmarked()
if (filterSet) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
DrawableCompat.setTint(menu.findItem(R.id.action_filter).icon, filterColor)
}
// Only show remove filter option if there's a filter set.
menuFilterEmpty.isVisible = filterSet
// Display mode submenu
if (presenter.manga.displayMode == Manga.DISPLAY_NAME) {
menu.findItem(R.id.display_title).isChecked = true
} else {
menu.findItem(R.id.display_chapter_number).isChecked = true
}
// Sorting mode submenu
val sortingItem = when (presenter.manga.sorting) {
Manga.SORTING_SOURCE -> R.id.sort_by_source
Manga.SORTING_NUMBER -> R.id.sort_by_number
Manga.SORTING_UPLOAD_DATE -> R.id.sort_by_upload_date
else -> throw NotImplementedError("Unimplemented sorting method")
}
menu.findItem(sortingItem).isChecked = true
menu.findItem(R.id.action_sort_descending).isChecked = presenter.manga.sortDescending()
// Hide download options for local manga
menu.findItem(R.id.download_group).isVisible = !isLocalSource
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.display_title -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NAME)
}
R.id.display_chapter_number -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NUMBER)
}
R.id.sort_by_source -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_SOURCE)
}
R.id.sort_by_number -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_NUMBER)
}
R.id.sort_by_upload_date -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_UPLOAD_DATE)
}
R.id.action_sort_descending -> {
presenter.reverseSortOrder()
activity?.invalidateOptionsMenu()
}
R.id.download_next, R.id.download_next_5, R.id.download_next_10,
R.id.download_custom, R.id.download_unread, R.id.download_all
-> downloadChapters(item.itemId)
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_migrate -> migrateManga()
}
return super.onOptionsItemSelected(item)
}
private fun updateRefreshing() {
binding.swipeRefresh.isRefreshing = isRefreshingInfo || isRefreshingChapters
}
// Manga info - start
/**
* Check if manga is initialized.
* If true update header with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextMangaInfo(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
mangaInfoAdapter?.update(manga, source)
} else {
// Initialize manga.
fetchMangaInfoFromSource()
}
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaInfoFromSource(manualFetch: Boolean = false) {
isRefreshingInfo = true
updateRefreshing()
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource(manualFetch)
}
fun onFetchMangaInfoDone() {
isRefreshingInfo = false
updateRefreshing()
}
fun onFetchMangaInfoError(error: Throwable) {
isRefreshingInfo = false
updateRefreshing()
activity?.toast(error.message)
}
fun openMangaInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url.toString()
} catch (e: Exception) {
return
}
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.manga.title)
startActivity(intent)
}
fun shareManga() {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url.toString()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
fun onFavoriteClick() {
val manga = presenter.manga
if (manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_removed_library))
} else {
addToLibrary(manga)
}
}
private fun addToLibrary(manga: Manga) {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
// Default category set
defaultCategory != null -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, defaultCategory)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, null)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
private fun toggleFavorite() {
val view = view
val isNowFavorite = presenter.toggleFavorite()
if (view != null && !isNowFavorite && presenter.hasDownloads()) {
view.snack(view.context.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
}
}
mangaInfoAdapter?.notifyDataSetChanged()
}
fun onCategoriesClick() {
val manga = presenter.manga
val categories = presenter.getCategories()
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
if (!manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_added_library))
}
presenter.moveMangaToCategories(manga, categories)
}
/**
* Perform a global search using the provided query.
*
* @param query the search query to pass to the search controller
*/
fun performGlobalSearch(query: String) {
val router = parentController?.router ?: return
router.pushController(GlobalSearchController(query).withFadeTransaction())
}
/**
* Perform a search using the provided query.
*
* @param query the search query to the parent controller
*/
fun performSearch(query: String) {
val router = parentController?.router ?: return
if (router.backstackSize < 2) {
return
}
when (val previousController = router.backstack[router.backstackSize - 2].controller()) {
is LibraryController -> {
router.handleBack()
previousController.search(query)
}
is UpdatesController,
is HistoryController -> {
// Manually navigate to LibraryController
router.handleBack()
(router.activity as MainActivity).setSelectedNavItem(R.id.nav_library)
val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController
controller.search(query)
}
is BrowseSourceController -> {
router.handleBack()
previousController.searchWithQuery(query)
}
}
}
// Manga info - end
// Chapters list - start
/**
* Initiates source migration for the specific manga.
*/
private fun migrateManga() {
val controller =
SearchController(
presenter.manga
)
controller.targetController = this
parentController!!.router.pushController(controller.withFadeTransaction())
}
fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty and it hasn't requested previously, fetch chapters from source
// We use presenter chapters instead because they are always unfiltered
if (!presenter.hasRequested && presenter.chapters.isEmpty()) {
fetchChaptersFromSource()
}
val chaptersHeader = chaptersHeaderAdapter ?: return
chaptersHeader.setNumChapters(chapters.size)
val adapter = chaptersAdapter ?: return
adapter.updateDataSet(chapters)
if (selectedChapters.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedChapters.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
val context = view?.context
if (context != null && chapters.any { it.read }) {
binding.fab.text = context.getString(R.string.action_resume)
}
}
private fun fetchChaptersFromSource(manualFetch: Boolean = false) {
isRefreshingChapters = true
updateRefreshing()
presenter.fetchChaptersFromSource(manualFetch)
}
fun onFetchChaptersDone() {
isRefreshingChapters = false
updateRefreshing()
}
fun onFetchChaptersError(error: Throwable) {
isRefreshingChapters = false
updateRefreshing()
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = chaptersAdapter ?: return false
val item = adapter.getItem(position) ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position
toggleSelection(position)
true
} else {
openChapter(item.chapter)
false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position ->
for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position ->
for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
lastClickPosition = position
chaptersAdapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedChapters.add(item)
} else {
selectedChapters.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedChapters.add(item)
actionMode?.invalidate()
}
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = chaptersAdapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
binding.actionToolbar.show(
actionMode!!,
R.menu.chapter_selection
) { onActionItemClicked(it!!) }
}
}
private fun destroyActionModeIfNeeded() {
lastClickPosition = -1
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
chaptersAdapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = chaptersAdapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = count.toString()
val chapters = getSelectedChapters()
binding.actionToolbar.findItem(R.id.action_download)?.isVisible = !isLocalSource && chapters.any { !it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = !isLocalSource && chapters.any { it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
// Hide FAB to avoid interfering with the bottom action toolbar
// binding.fab.hide()
binding.fab.gone()
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return onActionItemClicked(item)
}
private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_select_inverse -> selectInverse()
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true)
R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false)
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters())
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
binding.actionToolbar.hide()
chaptersAdapter?.mode = SelectableAdapter.Mode.SINGLE
chaptersAdapter?.clearSelection()
selectedChapters.clear()
actionMode = null
// TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton]
// fails to show up properly
// binding.fab.show()
binding.fab.visible()
}
override fun onDetach(view: View) {
destroyActionModeIfNeeded()
super.onDetach(view)
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = chaptersAdapter ?: return
adapter.selectAll()
selectedChapters.addAll(adapter.items)
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = chaptersAdapter ?: return
selectedChapters.clear()
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
selectedChapters.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) })
actionMode?.invalidate()
adapter.notifyDataSetChanged()
}
private fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
destroyActionModeIfNeeded()
}
private fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
destroyActionModeIfNeeded()
}
private fun downloadChapters(chapters: List<ChapterItem>) {
val view = view
val manga = presenter.manga
presenter.downloadChapters(chapters)
if (view != null && !manga.favorite) {
binding.recycler.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
addToLibrary(manga)
}
}
}
destroyActionModeIfNeeded()
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapters: List<ChapterItem>) {
val adapter = chaptersAdapter ?: return
val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = prevChapters.indexOf(chapters.last())
if (chapterPos != -1) {
markAsRead(prevChapters.take(chapterPos))
}
destroyActionModeIfNeeded()
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
fun deleteChapters(chapters: List<ChapterItem>) {
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
destroyActionModeIfNeeded()
}
fun onChaptersDeleted(chapters: List<ChapterItem>) {
// this is needed so the downloaded text gets removed from the item
chapters.forEach {
chaptersAdapter?.updateItem(it)
}
chaptersAdapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
Timber.e(error)
}
// OVERFLOW MENU DIALOGS
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
chaptersAdapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
private fun downloadChapters(choice: Int) {
val chaptersToDownload = when (choice) {
R.id.download_next -> getUnreadChaptersSorted().take(1)
R.id.download_next_5 -> getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> getUnreadChaptersSorted().take(10)
R.id.download_custom -> {
showCustomDownloadDialog()
return
}
R.id.download_unread -> presenter.chapters.filter { !it.read }
R.id.download_all -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
destroyActionModeIfNeeded()
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
// Chapters list - end
}

View file

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.ui.manga.chapter
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.util.AttributeSet

View file

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.ui.manga.chapter
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.text.TextUtils
@ -13,11 +13,13 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.setChips
@ -35,7 +37,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaInfoHeaderAdapter(
private val controller: MangaInfoChaptersController,
private val controller: MangaController,
private val fromSource: Boolean
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
@ -81,13 +83,24 @@ class MangaInfoHeaderAdapter(
.onEach { controller.onFavoriteClick() }
.launchIn(scope)
if (controller.presenter.manga.favorite && Injekt.get<TrackManager>().hasLoggedServices()) {
binding.btnTracking.visible()
binding.btnTracking.clicks()
.onEach { controller.onTrackingClick() }
.launchIn(scope)
} else {
binding.btnTracking.gone()
}
if (controller.presenter.manga.favorite && controller.presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
binding.btnCategories.clicks()
.onEach { controller.onCategoriesClick() }
.launchIn(scope)
binding.btnCategories.setTooltip(R.string.action_move_category)
} else {
binding.btnCategories.gone()
}
binding.btnCategories.clicks()
.onEach { controller.onCategoriesClick() }
.launchIn(scope)
binding.btnCategories.setTooltip(R.string.action_move_category)
if (controller.presenter.source is HttpSource) {
binding.btnWebview.visible()

View file

@ -2,29 +2,51 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TrackController :
NucleusController<TrackControllerBinding, TrackPresenter>(),
NucleusController<TrackControllerBinding, TrackPresenter>,
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener,
SetTrackReadingDatesDialog.Listener {
constructor(manga: Manga?) : super(
Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
}
) {
this.manga = manga
}
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()
)
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
var manga: Manga? = null
private set
private var adapter: TrackAdapter? = null
init {
@ -33,8 +55,12 @@ class TrackController :
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return manga?.title
}
override fun createPresenter(): TrackPresenter {
return TrackPresenter((parentController as MangaController).manga!!)
return TrackPresenter(manga!!)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
@ -45,6 +71,8 @@ class TrackController :
override fun onViewCreated(view: View) {
super.onViewCreated(view)
if (manga == null) return
adapter = TrackAdapter(this)
binding.trackRecycler.layoutManager = LinearLayoutManager(view.context)
binding.trackRecycler.adapter = adapter
@ -63,7 +91,6 @@ class TrackController :
val atLeastOneLink = trackings.any { it.track != null }
adapter?.items = trackings
binding.swipeRefresh.isEnabled = atLeastOneLink
(parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
}
fun onSearchResults(results: List<TrackSearch>) {
@ -167,6 +194,7 @@ class TrackController :
}
private companion object {
const val MANGA_EXTRA = "manga"
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}

View file

@ -32,17 +32,17 @@
android:id="@+id/manga_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent">
<eu.kanade.tachiyomi.ui.manga.chapter.MangaCoverImageView
<eu.kanade.tachiyomi.ui.manga.info.MangaCoverImageView
android:id="@+id/manga_cover"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxWidth="220dp"
android:background="@drawable/rounded_rectangle"
android:contentDescription="@string/description_cover"
android:maxWidth="220dp"
tools:src="@mipmap/ic_launcher" />
<LinearLayout
@ -126,6 +126,17 @@
android:text="@string/add_to_library"
app:icon="@drawable/ic_favorite_border_24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tracking"
style="@style/Theme.Widget.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/manga_tracking_tab"
android:visibility="gone"
app:icon="@drawable/ic_sync_24dp"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_categories"
style="@style/Theme.Widget.Button.Icon.Textless"