Merge manga info and chapters views

This commit is contained in:
arkon 2020-06-18 22:15:05 -04:00
parent a768280d82
commit 4605e14729
12 changed files with 1152 additions and 1401 deletions

View file

@ -24,11 +24,9 @@ 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.ChaptersController
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController
import eu.kanade.tachiyomi.ui.manga.chapter.MangaInfoChaptersController
import eu.kanade.tachiyomi.ui.manga.track.TrackController
import eu.kanade.tachiyomi.util.system.toast
import java.util.Date
import kotlinx.android.synthetic.main.main_activity.tabs
import rx.Subscription
import uy.kohesive.injekt.Injekt
@ -65,10 +63,6 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false)
val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create()
val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create()
val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create()
private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create()
@ -92,17 +86,12 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
adapter = MangaDetailAdapter()
binding.pager.offscreenPageLimit = 3
binding.pager.adapter = adapter
if (!fromSource) {
binding.pager.currentItem = CHAPTERS_CONTROLLER
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
super.onDestroyView(view)
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
@ -150,15 +139,14 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) {
private val tabCount = if (Injekt.get<TrackManager>().hasLoggedServices()) 3 else 2
private val tabTitles = listOf(
R.string.manga_detail_tab,
R.string.manga_chapters_tab,
R.string.manga_tracking_tab
)
.map { resources!!.getString(it) }
private val tabCount = tabTitles.size - if (Injekt.get<TrackManager>().hasLoggedServices()) 0 else 1
override fun getCount(): Int {
return tabCount
}
@ -166,8 +154,7 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
override fun configureRouter(router: Router, position: Int) {
if (!router.hasRootController()) {
val controller = when (position) {
INFO_CONTROLLER -> MangaInfoController(fromSource)
CHAPTERS_CONTROLLER -> ChaptersController()
INFO_CHAPTERS_CONTROLLER -> MangaInfoChaptersController(fromSource)
TRACK_CONTROLLER -> TrackController()
else -> error("Wrong position $position")
}
@ -184,8 +171,7 @@ class MangaController : RxController<PagerControllerBinding>, TabbedController {
const val FROM_SOURCE_EXTRA = "from_source"
const val MANGA_EXTRA = "manga"
const val INFO_CONTROLLER = 0
const val CHAPTERS_CONTROLLER = 1
const val TRACK_CONTROLLER = 2
const val INFO_CHAPTERS_CONTROLLER = 0
const val TRACK_CONTROLLER = 1
}
}

View file

@ -11,7 +11,7 @@ import java.text.DecimalFormatSymbols
import uy.kohesive.injekt.injectLazy
class ChaptersAdapter(
controller: ChaptersController,
controller: MangaInfoChaptersController,
context: Context
) : FlexibleAdapter<ChapterItem>(null, controller, true) {

View file

@ -15,19 +15,34 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.MergeAdapter
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
@ -40,19 +55,21 @@ 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 ChaptersController :
NucleusController<ChaptersControllerBinding, ChaptersPresenter>(),
class MangaInfoChaptersController(private val fromSource: Boolean = false) :
NucleusController<ChaptersControllerBinding, MangaInfoChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
private val preferences: PreferencesHelper by injectLazy()
private var headerAdapter: MangaInfoHeaderAdapter? = null
private var chaptersAdapter: ChaptersAdapter? = null
/**
* Action mode for multiple selection.
@ -62,20 +79,22 @@ class ChaptersController :
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
private val selectedChapters = mutableSetOf<ChapterItem>()
private var lastClickPosition = -1
private var isRefreshingInfo = false
private var isRefreshingChapters = false
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): ChaptersPresenter {
override fun createPresenter(): MangaInfoChaptersPresenter {
val ctrl = parentController as MangaController
return ChaptersPresenter(
ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay
return MangaInfoChaptersPresenter(
ctrl.manga!!, ctrl.source!!, ctrl.mangaFavoriteRelay
)
}
@ -91,16 +110,20 @@ class ChaptersController :
if (ctrl.manga == null || ctrl.source == null) return
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
headerAdapter = MangaInfoHeaderAdapter(this, fromSource)
chaptersAdapter = ChaptersAdapter(this, view.context)
binding.recycler.adapter = adapter
binding.recycler.adapter = MergeAdapter(headerAdapter, chaptersAdapter)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recycler.setHasFixedSize(true)
adapter?.fastScroller = binding.fastScroller
chaptersAdapter?.fastScroller = binding.fastScroller
binding.swipeRefresh.refreshes()
.onEach { fetchChaptersFromSource(manualFetch = true) }
.onEach {
fetchMangaInfoFromSource(manualFetch = true)
fetchChaptersFromSource(manualFetch = true)
}
.launchIn(scope)
binding.fab.clicks()
@ -134,7 +157,8 @@ class ChaptersController :
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
binding.actionToolbar.destroy()
adapter = null
headerAdapter = null
chaptersAdapter = null
super.onDestroyView(view)
}
@ -171,7 +195,6 @@ class ChaptersController :
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)
@ -259,10 +282,228 @@ class ChaptersController :
activity?.invalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
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.
headerAdapter?.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 {
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()
}
}
}
headerAdapter?.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
@ -270,13 +511,13 @@ class ChaptersController :
fetchChaptersFromSource()
}
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
adapter.updateDataSet(chapters)
if (selectedItems.isNotEmpty()) {
if (selectedChapters.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedItems.forEach { item ->
selectedChapters.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
@ -292,16 +533,20 @@ class ChaptersController :
}
private fun fetchChaptersFromSource(manualFetch: Boolean = false) {
binding.swipeRefresh.isRefreshing = true
isRefreshingChapters = true
updateRefreshing()
presenter.fetchChaptersFromSource(manualFetch)
}
fun onFetchChaptersDone() {
binding.swipeRefresh.isRefreshing = false
isRefreshingChapters = false
updateRefreshing()
}
fun onFetchChaptersError(error: Throwable) {
binding.swipeRefresh.isRefreshing = false
isRefreshingChapters = false
updateRefreshing()
activity?.toast(error.message)
}
@ -323,7 +568,7 @@ class ChaptersController :
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = adapter ?: return false
val adapter = chaptersAdapter ?: return false
val item = adapter.getItem(position) ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position
@ -348,36 +593,36 @@ class ChaptersController :
else -> setSelection(position)
}
lastClickPosition = position
adapter?.notifyDataSetChanged()
chaptersAdapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedItems.add(item)
selectedChapters.add(item)
} else {
selectedItems.remove(item)
selectedChapters.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedItems.add(item)
selectedChapters.add(item)
actionMode?.invalidate()
}
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
val adapter = chaptersAdapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
@ -398,12 +643,12 @@ class ChaptersController :
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
chaptersAdapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
val count = chaptersAdapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
@ -448,9 +693,9 @@ class ChaptersController :
override fun onDestroyActionMode(mode: ActionMode) {
binding.actionToolbar.hide()
adapter?.mode = SelectableAdapter.Mode.SINGLE
adapter?.clearSelection()
selectedItems.clear()
chaptersAdapter?.mode = SelectableAdapter.Mode.SINGLE
chaptersAdapter?.clearSelection()
selectedChapters.clear()
actionMode = null
// TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton]
@ -467,20 +712,20 @@ class ChaptersController :
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
selectedChapters.addAll(adapter.items)
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
selectedItems.clear()
selectedChapters.clear()
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
selectedItems.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) })
selectedChapters.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) })
actionMode?.invalidate()
adapter.notifyDataSetChanged()
@ -521,7 +766,7 @@ class ChaptersController :
}
private fun markPreviousAsRead(chapters: List<ChapterItem>) {
val adapter = adapter ?: return
val adapter = chaptersAdapter ?: return
val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = prevChapters.indexOf(chapters.last())
if (chapterPos != -1) {
@ -545,9 +790,9 @@ class ChaptersController :
fun onChaptersDeleted(chapters: List<ChapterItem>) {
// this is needed so the downloaded text gets removed from the item
chapters.forEach {
adapter?.updateItem(it)
chaptersAdapter?.updateItem(it)
}
adapter?.notifyDataSetChanged()
chaptersAdapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
@ -558,7 +803,7 @@ class ChaptersController :
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
chaptersAdapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
@ -595,4 +840,6 @@ class ChaptersController :
downloadChapters(chaptersToDownload)
}
}
// Chapters list - end
}

View file

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
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.Chapter
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.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -14,8 +16,9 @@ import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import java.util.Date
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
@ -24,16 +27,20 @@ import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ChaptersPresenter(
class MangaInfoChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get()
) : BasePresenter<MangaInfoChaptersController>() {
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
@ -67,10 +74,24 @@ class ChaptersPresenter(
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Manga info - start
getMangaObservable()
.subscribeLatestCache({ view, manga -> view.onNextMangaInfo(manga, source) })
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersController::onNextChapters) { _, error -> Timber.e(error) }
.subscribeLatestCache(MangaInfoChaptersController::onNextChapters) { _, error -> Timber.e(error) }
// Manga info - end
// Chapters list - start
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
@ -89,32 +110,130 @@ class ChaptersPresenter(
// Listen for download status changes
observeDownloads()
// Emit the number of chapters to the info tab.
chapterCountRelay.call(
chapters.maxBy { it.chapter_number }?.chapter_number
?: 0f
)
// Emit the upload date of the most recent chapter
lastUpdateRelay.call(
Date(
chapters.maxBy { it.date_upload }?.date_upload
?: 0
)
)
}
.subscribe { chaptersRelay.call(it) }
)
// Chapters list - end
}
// Manga info - start
private fun getMangaObservable(): Observable<Manga> {
return db.getManga(manga.url, manga.source).asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource(manualFetch: Boolean = false) {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.prepUpdateCover(coverCache, networkManga, manualFetch)
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchMangaInfoDone()
},
MangaInfoChaptersController::onFetchMangaInfoError
)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
if (!manga.favorite) {
manga.removeCovers(coverCache)
}
db.insertManga(manga).executeAsBlocking()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
// Manga info - end
// Chapters list - start
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
observeDownloadsSubscription = downloadManager.queue.getStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersController::onChapterStatusChange) { _, error ->
.subscribeLatestCache(MangaInfoChaptersController::onChapterStatusChange) { _, error ->
Timber.e(error)
}
}
@ -167,7 +286,7 @@ class ChaptersPresenter(
{ view, _ ->
view.onFetchChaptersDone()
},
ChaptersController::onFetchChaptersError
MangaInfoChaptersController::onFetchChaptersError
)
}
@ -297,7 +416,7 @@ class ChaptersPresenter(
{ view, _ ->
view.onChaptersDeleted(chapters)
},
ChaptersController::onChaptersDeletedError
MangaInfoChaptersController::onChaptersDeletedError
)
}
@ -446,4 +565,6 @@ class ChaptersPresenter(
fun sortDescending(): Boolean {
return manga.sortDescending()
}
// Chapters list - end
}

View file

@ -0,0 +1,316 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.load.engine.DiskCacheStrategy
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.databinding.MangaInfoControllerBinding
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.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.setChips
import eu.kanade.tachiyomi.util.view.visible
import eu.kanade.tachiyomi.util.view.visibleIf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaInfoHeaderAdapter(
private val controller: MangaInfoChaptersController,
private val fromSource: Boolean
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
private var manga: Manga? = null
private var source: Source? = null
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private lateinit var binding: MangaInfoControllerBinding
private var initialLoad: Boolean = true
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoControllerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun update(manga: Manga, source: Source?) {
this.manga = manga
this.source = source
notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
if (manga == null) {
return
}
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(scope)
if (controller.presenter.manga.favorite && controller.presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
}
binding.btnCategories.clicks()
.onEach { controller.onCategoriesClick() }
.launchIn(scope)
if (controller.presenter.source is HttpSource) {
binding.btnWebview.visible()
binding.btnShare.visible()
binding.btnWebview.clicks()
.onEach { controller.openMangaInWebView() }
.launchIn(scope)
binding.btnShare.clicks()
.onEach { controller.shareManga() }
.launchIn(scope)
}
binding.mangaFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString()
)
}
.launchIn(scope)
binding.mangaFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(scope)
binding.mangaAuthor.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString()
)
}
.launchIn(scope)
binding.mangaAuthor.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaAuthor.text.toString())
}
.launchIn(scope)
binding.mangaSummary.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.description),
binding.mangaSummary.text.toString()
)
}
.launchIn(scope)
binding.mangaCover.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
controller.presenter.manga.title
)
}
.launchIn(scope)
setMangaInfo(manga!!, source)
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
// update full title TextView.
binding.mangaFullTitle.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.title
}
// Update author/artist TextView.
val authors = listOf(manga.author, manga.artist).filter { !it.isNullOrBlank() }.distinct()
binding.mangaAuthor.text = if (authors.isEmpty()) {
view.context.getString(R.string.unknown)
} else {
authors.joinToString(", ")
}
// If manga source is known update source TextView.
val mangaSource = source?.toString()
with(binding.mangaSource) {
if (mangaSource != null) {
text = mangaSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
} else {
text = view.context.getString(R.string.unknown)
}
}
// Update status TextView.
binding.mangaStatus.setText(
when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
}
)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already.
val mangaThumbnail = manga.toMangaThumbnail()
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.mangaCover)
binding.backdrop?.let {
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(it)
}
// Manga info section
if (manga.description.isNullOrBlank() && manga.genre.isNullOrBlank()) {
hideMangaInfo()
} else {
// Update description TextView.
binding.mangaSummary.text = if (manga.description.isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.description
}
// Update genres list
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChips(manga.getGenres(), controller::performSearch)
binding.mangaGenresTagsFullChips.setChips(manga.getGenres(), controller::performSearch)
} else {
binding.mangaGenresTagsWrapper.gone()
}
// Handle showing more or less info
binding.mangaSummary.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
binding.mangaInfoToggle.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
// Expand manga info if navigated from source listing
if (initialLoad && fromSource) {
toggleMangaInfo(view.context)
initialLoad = false
}
}
binding.btnCategories.visibleIf { manga.favorite && controller.presenter.getCategories().isNotEmpty() }
}
private fun hideMangaInfo() {
binding.mangaSummaryLabel.gone()
binding.mangaSummary.gone()
binding.mangaGenresTagsWrapper.gone()
binding.mangaInfoToggle.gone()
}
private fun toggleMangaInfo(context: Context) {
val isExpanded =
binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse)
binding.mangaInfoToggle.text =
if (isExpanded) {
context.getString(R.string.manga_info_expand)
} else {
context.getString(R.string.manga_info_collapse)
}
with(binding.mangaSummary) {
maxLines =
if (isExpanded) {
3
} else {
Int.MAX_VALUE
}
ellipsize =
if (isExpanded) {
TextUtils.TruncateAt.END
} else {
null
}
}
binding.mangaGenresTagsCompact.visibleIf { isExpanded }
binding.mangaGenresTagsFullChips.visibleIf { !isExpanded }
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
icon = ContextCompat.getDrawable(
context,
if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp
)
text =
context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isChecked = isFavorite
}
}
}
}

View file

@ -1,585 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.content.Intent
import android.text.TextUtils
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.core.content.ContextCompat
import com.bumptech.glide.load.engine.DiskCacheStrategy
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.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MangaInfoControllerBinding
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.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.manga.MangaController
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.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.setChips
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import eu.kanade.tachiyomi.util.view.visibleIf
import java.text.DateFormat
import java.text.DecimalFormat
import java.util.Date
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
/**
* Fragment that shows manga information.
* Uses R.layout.manga_info_controller.
* UI related actions should be called from here.
*/
class MangaInfoController(private val fromSource: Boolean = false) :
NucleusController<MangaInfoControllerBinding, MangaInfoPresenter>(),
ChangeMangaCategoriesDialog.Listener {
private val preferences: PreferencesHelper by injectLazy()
private val dateFormat: DateFormat by lazy {
preferences.dateFormat()
}
private var initialLoad: Boolean = true
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): MangaInfoPresenter {
val ctrl = parentController as MangaController
return MangaInfoPresenter(
ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay
)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = MangaInfoControllerBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { onFavoriteClick() }
.launchIn(scope)
if (presenter.manga.favorite && presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
}
binding.btnCategories.clicks()
.onEach { onCategoriesClick() }
.launchIn(scope)
if (presenter.source is HttpSource) {
binding.btnWebview.visible()
binding.btnShare.visible()
binding.btnWebview.clicks()
.onEach { openInWebView() }
.launchIn(scope)
binding.btnShare.clicks()
.onEach { shareManga() }
.launchIn(scope)
}
// Set SwipeRefresh to refresh manga data.
binding.swipeRefresh.refreshes()
.onEach { fetchMangaFromSource(manualFetch = true) }
.launchIn(scope)
binding.mangaFullTitle.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString()
)
}
.launchIn(scope)
binding.mangaFullTitle.clicks()
.onEach {
performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(scope)
binding.mangaAuthor.longClicks()
.onEach {
activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString()
)
}
.launchIn(scope)
binding.mangaAuthor.clicks()
.onEach {
performGlobalSearch(binding.mangaAuthor.text.toString())
}
.launchIn(scope)
binding.mangaSummary.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.description),
binding.mangaSummary.text.toString()
)
}
.launchIn(scope)
binding.mangaCover.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.title),
presenter.manga.title
)
}
.launchIn(scope)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.manga_info, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_migrate -> migrateManga()
}
return super.onOptionsItemSelected(item)
}
/**
* Check if manga is initialized.
* If true update view with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextManga(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
setMangaInfo(manga, source)
} else {
// Initialize manga.
fetchMangaFromSource()
}
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
val view = view ?: return
// update full title TextView.
binding.mangaFullTitle.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.title
}
// Update author/artist TextView.
val authors = listOf(manga.author, manga.artist).filter { !it.isNullOrBlank() }.distinct()
binding.mangaAuthor.text = if (authors.isEmpty()) {
view.context.getString(R.string.unknown)
} else {
authors.joinToString(", ")
}
// If manga source is known update source TextView.
val mangaSource = source?.toString()
with(binding.mangaSource) {
if (mangaSource != null) {
text = mangaSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
performSearch(sourceManager.getOrStub(source.id).name)
}
} else {
text = view.context.getString(R.string.unknown)
}
}
// Update status TextView.
binding.mangaStatus.setText(
when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
}
)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already.
val mangaThumbnail = manga.toMangaThumbnail()
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.mangaCover)
binding.backdrop?.let {
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(it)
}
// Manga info section
if (manga.description.isNullOrBlank() && manga.genre.isNullOrBlank()) {
hideMangaInfo()
} else {
// Update description TextView.
binding.mangaSummary.text = if (manga.description.isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.description
}
// Update genres list
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChips(manga.getGenres(), this::performSearch)
binding.mangaGenresTagsFullChips.setChips(manga.getGenres(), this::performSearch)
} else {
binding.mangaGenresTagsWrapper.gone()
}
// Handle showing more or less info
binding.mangaSummary.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
binding.mangaInfoToggle.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
// Expand manga info if navigated from source listing
if (initialLoad && fromSource) {
toggleMangaInfo(view.context)
initialLoad = false
}
}
}
private fun hideMangaInfo() {
binding.mangaSummaryLabel.gone()
binding.mangaSummary.gone()
binding.mangaGenresTagsWrapper.gone()
binding.mangaInfoToggle.gone()
}
private fun toggleMangaInfo(context: Context) {
val isExpanded =
binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse)
binding.mangaInfoToggle.text =
if (isExpanded) {
context.getString(R.string.manga_info_expand)
} else {
context.getString(R.string.manga_info_collapse)
}
with(binding.mangaSummary) {
maxLines =
if (isExpanded) {
3
} else {
Int.MAX_VALUE
}
ellipsize =
if (isExpanded) {
TextUtils.TruncateAt.END
} else {
null
}
}
binding.mangaGenresTagsCompact.visibleIf { isExpanded }
binding.mangaGenresTagsFullChips.visibleIf { !isExpanded }
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Float) {
if (count > 0f) {
binding.mangaChapters.text = DecimalFormat("#.#").format(count)
} else {
binding.mangaChapters.text = resources?.getString(R.string.unknown)
}
}
fun setLastUpdateDate(date: Date) {
if (date.time != 0L) {
binding.mangaLastUpdate.text = dateFormat.format(date)
} else {
binding.mangaLastUpdate.text = resources?.getString(R.string.unknown)
}
}
/**
* 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()
}
}
}
binding.btnCategories.visibleIf { isNowFavorite && presenter.getCategories().isNotEmpty() }
}
private fun openInWebView() {
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)
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
private 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)
}
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
icon = ContextCompat.getDrawable(
context,
if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp
)
text =
context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isChecked = isFavorite
}
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaFromSource(manualFetch: Boolean = false) {
setRefreshing(true)
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource(manualFetch)
}
/**
* Update swipe refresh to stop showing refresh in progress spinner.
*/
fun onFetchMangaDone() {
setRefreshing(false)
}
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError(error: Throwable) {
setRefreshing(false)
activity?.toast(error.message)
}
/**
* Set swipe refresh status.
*
* @param value whether it should be refreshing or not.
*/
private fun setRefreshing(value: Boolean) {
binding.swipeRefresh.isRefreshing = value
}
private fun onFavoriteClick() {
val manga = presenter.manga
if (manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_removed_library))
} else {
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)
}
}
}
}
private 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)
}
/**
* Initiates source migration for the specific manga.
*/
private fun migrateManga() {
val controller =
SearchController(
presenter.manga
)
controller.targetController = this
parentController!!.router.pushController(controller.withFadeTransaction())
}
/**
* Perform a global search using the provided query.
*
* @param query the search query to pass to the search controller
*/
private 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
*/
private 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)
}
}
}
}

View file

@ -1,169 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
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.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import java.util.Date
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
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get()
) : BasePresenter<MangaInfoController>() {
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
getMangaObservable()
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
// Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setChapterCount)
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
// update last update date
lastUpdateRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setLastUpdateDate)
}
private fun getMangaObservable(): Observable<Manga> {
return db.getManga(manga.url, manga.source).asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource(manualFetch: Boolean = false) {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.prepUpdateCover(coverCache, networkManga, manualFetch)
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchMangaDone()
},
MangaInfoController::onFetchMangaError
)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
if (!manga.favorite) {
manga.removeCovers(coverCache)
}
db.insertManga(manga).executeAsBlocking()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
}

View file

@ -1,16 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/manga_cover"
android:layout_width="0dp"
@ -58,103 +53,36 @@
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
tools:text="Author" />
<TextView
android:id="@+id/manga_chapters_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/manga_info_last_chapter_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_author" />
<TextView
android:id="@+id/manga_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_chapters_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_chapters_label" />
<TextView
android:id="@+id/manga_last_update_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_latest_data_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_chapters_label" />
<TextView
android:id="@+id/manga_last_update"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_last_update_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_last_update_label" />
<TextView
android:id="@+id/manga_status_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_status_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_last_update_label" />
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_status_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_status_label" />
<TextView
android:id="@+id/manga_source_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_source_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
app:layout_constraintTop_toBottomOf="@+id/manga_author" />
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_source_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_source_label" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_status" />
<LinearLayout
android:id="@+id/actions_bar"
@ -287,6 +215,4 @@
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,21 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="8dp">
android:paddingBottom="8dp"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@ -84,104 +75,37 @@
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textIsSelectable="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
tools:text="Author" />
<TextView
android:id="@+id/manga_chapters_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/manga_info_last_chapter_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_author" />
<TextView
android:id="@+id/manga_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_chapters_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_chapters_label" />
<TextView
android:id="@+id/manga_last_update_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_latest_data_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_chapters_label" />
<TextView
android:id="@+id/manga_last_update"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_last_update_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_last_update_label" />
<TextView
android:id="@+id/manga_status_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_status_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_last_update_label" />
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_status_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_status_label" />
<TextView
android:id="@+id/manga_source_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_source_label"
android:textIsSelectable="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
app:layout_constraintTop_toBottomOf="@+id/manga_author" />
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_source_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/manga_source_label" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manga_status" />
</androidx.constraintlayout.widget.ConstraintLayout>
@ -309,8 +233,4 @@
android:text="@string/manga_info_expand"
android:textSize="12sp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
</LinearLayout>

View file

@ -95,4 +95,9 @@
android:title="@string/download_all" />
</menu>
</item>
<item
android:id="@+id/action_migrate"
android:title="@string/action_migrate"
app:showAsAction="never" />
</menu>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_migrate"
android:title="@string/action_migrate"
app:showAsAction="never" />
</menu>

View file

@ -458,12 +458,6 @@
<string name="manga_info_full_title_label">Title</string>
<string name="manga_added_library">Added to library</string>
<string name="manga_removed_library">Removed from library</string>
<string name="manga_info_chapters_label">Chapters</string>
<string name="manga_info_last_chapter_label">Last chapter</string>
<string name="manga_info_latest_data_label">Updated</string>
<string name="manga_info_status_label">Status</string>
<string name="manga_info_source_label">Source</string>
<string name="manga_info_genres_label">Genres</string>
<string name="manga_info_about_label">About</string>
<string name="manga_info_expand">Show more info</string>
<string name="manga_info_collapse">Show less info</string>