From 4605e147296697ebf5a1318bac6b49519a48aa99 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 18 Jun 2020 22:15:05 -0400 Subject: [PATCH] Merge manga info and chapters views --- .../tachiyomi/ui/manga/MangaController.kt | 28 +- .../ui/manga/chapter/ChaptersAdapter.kt | 2 +- ...ller.kt => MangaInfoChaptersController.kt} | 337 ++++++++-- ...enter.kt => MangaInfoChaptersPresenter.kt} | 171 ++++- .../manga/chapter/MangaInfoHeaderAdapter.kt | 316 ++++++++++ .../ui/manga/info/MangaInfoController.kt | 585 ------------------ .../ui/manga/info/MangaInfoPresenter.kt | 169 ----- .../res/layout-land/manga_info_controller.xml | 424 ++++++------- .../main/res/layout/manga_info_controller.xml | 500 +++++++-------- app/src/main/res/menu/chapters.xml | 5 + app/src/main/res/menu/manga_info.xml | 10 - app/src/main/res/values/strings.xml | 6 - 12 files changed, 1152 insertions(+), 1401 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/{ChaptersController.kt => MangaInfoChaptersController.kt} (65%) rename app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/{ChaptersPresenter.kt => MangaInfoChaptersPresenter.kt} (74%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoHeaderAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt delete mode 100644 app/src/main/res/menu/manga_info.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 2e8ba19f07..728f5bdde5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -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, TabbedController { val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) - val lastUpdateRelay: BehaviorRelay = BehaviorRelay.create() - - val chapterCountRelay: BehaviorRelay = BehaviorRelay.create() - val mangaFavoriteRelay: PublishRelay = PublishRelay.create() private val trackingIconRelay: BehaviorRelay = BehaviorRelay.create() @@ -92,17 +86,12 @@ class MangaController : RxController, 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, TabbedController { private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { - private val tabCount = if (Injekt.get().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().hasLoggedServices()) 0 else 1 + override fun getCount(): Int { return tabCount } @@ -166,8 +154,7 @@ class MangaController : RxController, 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, 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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt index 5de8674174..0275502c08 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt @@ -11,7 +11,7 @@ import java.text.DecimalFormatSymbols import uy.kohesive.injekt.injectLazy class ChaptersAdapter( - controller: ChaptersController, + controller: MangaInfoChaptersController, context: Context ) : FlexibleAdapter(null, controller, true) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersController.kt similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersController.kt index 920b0ba931..759beddd7d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersController.kt @@ -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(), +class MangaInfoChaptersController(private val fromSource: Boolean = false) : + NucleusController(), 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() + private val selectedChapters = mutableSetOf() 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, categories: List) { + 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) { // 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 { - 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) { - 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) { // 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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersPresenter.kt similarity index 74% rename from app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersPresenter.kt index dda0d31d48..42a01bf200 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoChaptersPresenter.kt @@ -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, - private val lastUpdateRelay: BehaviorRelay, private val mangaFavoriteRelay: PublishRelay, val preferences: PreferencesHelper = Injekt.get(), private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get() +) : BasePresenter() { + + /** + * 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 { + 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 { + 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 { + 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) { + 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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoHeaderAdapter.kt new file mode 100644 index 0000000000..19906692a7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/MangaInfoHeaderAdapter.kt @@ -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() { + + 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() + 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 + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt deleted file mode 100644 index c909c379ad..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ /dev/null @@ -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(), - 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() - 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, categories: List) { - 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) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt deleted file mode 100644 index 5e37648523..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt +++ /dev/null @@ -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, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get() -) : BasePresenter() { - - /** - * 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 { - 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 { - 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 { - 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) { - 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)) - } -} diff --git a/app/src/main/res/layout-land/manga_info_controller.xml b/app/src/main/res/layout-land/manga_info_controller.xml index 934dd9c4b9..1b47c2b3f6 100644 --- a/app/src/main/res/layout-land/manga_info_controller.xml +++ b/app/src/main/res/layout-land/manga_info_controller.xml @@ -1,292 +1,218 @@ - - + - + - + - + + + + + + + + + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingTop="8dp" + android:paddingBottom="8dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/manga_source"> - + android:text="@string/add_to_library" + app:icon="@drawable/ic_favorite_border_24dp" /> - - - - - + android:contentDescription="@string/action_edit_categories" + android:visibility="gone" + app:icon="@drawable/ic_label_24dp" + tools:visibility="visible" /> - - - + android:contentDescription="@string/action_share" + android:visibility="gone" + app:icon="@drawable/ic_share_24dp" + tools:visibility="visible" /> - - - + android:contentDescription="@string/action_open_in_web_view" + android:visibility="gone" + app:icon="@drawable/ic_public_24dp" + tools:visibility="visible" /> - + - + - + + + + + android:visibility="gone" + app:chipSpacingHorizontal="4dp" /> - - - - - - - - - - - - - - - + android:requiresFadingEdge="horizontal"> + app:chipSpacingHorizontal="4dp" + app:singleLine="true" /> - + - + - +