diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index b8bb192a9..69be95947 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -230,4 +230,6 @@ object PreferenceKeys { const val eh_tag_watching_value = "eh_tag_watching_value" const val eh_is_hentai_enabled = "eh_is_hentai_enabled" + + const val eh_use_new_manga_interface = "eh_use_new_manga_interface" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 98f54fd89..d65e26a0d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -340,4 +340,6 @@ class PreferencesHelper(val context: Context) { fun eh_hl_useHighQualityThumbs() = flowPrefs.getBoolean(Keys.eh_hl_useHighQualityThumbs, false) fun eh_preload_size() = flowPrefs.getInt(Keys.eh_preload_size, 4) + + fun eh_useNewMangaInterface() = flowPrefs.getBoolean(Keys.eh_use_new_manga_interface, true) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 14209fe54..5a4821f8c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -38,6 +38,7 @@ import eu.kanade.tachiyomi.ui.browse.source.SourceController import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet.FilterNavigationView.Companion.MAX_SAVED_SEARCHES import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.connectivityManager @@ -648,13 +649,25 @@ open class BrowseSourceController(bundle: Bundle) : val item = adapter?.getItem(position) as? SourceItem ?: return false when (mode) { - Mode.CATALOGUE -> router.pushController( - MangaController( - item.manga, - true, - args.getParcelable(SMART_SEARCH_CONFIG_KEY) - ).withFadeTransaction() - ) + Mode.CATALOGUE -> { + if (preferences.eh_useNewMangaInterface().get()) { + router.pushController( + MangaAllInOneController( + item.manga, + true, + args.getParcelable(SMART_SEARCH_CONFIG_KEY) + ).withFadeTransaction() + ) + } else { + router.pushController( + MangaController( + item.manga, + true, + args.getParcelable(SMART_SEARCH_CONFIG_KEY) + ).withFadeTransaction() + ) + } + } Mode.RECOMMENDS -> openSmartSearch(item.manga.title) } return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt index 46dc3b087..1bd32da37 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn @@ -83,7 +84,11 @@ open class GlobalSearchController( */ override fun onMangaClick(manga: Manga) { // Open MangaController. - router.pushController(MangaController(manga, true).withFadeTransaction()) + if (preferences.eh_useNewMangaInterface().get()) { + router.pushController(MangaAllInOneController(manga, true).withFadeTransaction()) + } else { + router.pushController(MangaController(manga, true).withFadeTransaction()) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 27cb9309c..b0125fee0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -36,6 +36,7 @@ import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.migration.MigrationController import eu.kanade.tachiyomi.ui.migration.manga.design.PreMigrationController @@ -514,7 +515,11 @@ class LibraryController( // Notify the presenter a manga is being opened. presenter.onOpenManga() - router.pushController(MangaController(manga).withFadeTransaction()) + if (preferences.eh_useNewMangaInterface().get()) { + router.pushController(MangaAllInOneController(manga).withFadeTransaction()) + } else { + router.pushController(MangaController(manga).withFadeTransaction()) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 1f3bebcbc..955ccddc2 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.more.MoreController import eu.kanade.tachiyomi.ui.recent.history.HistoryController @@ -296,7 +297,11 @@ class MainActivity : BaseActivity() { router.popToRoot() } setSelectedNavItem(R.id.nav_library) - router.pushController(RouterTransaction.with(MangaController(extras))) + if (preferences.eh_useNewMangaInterface().get()) { + router.pushController(RouterTransaction.with(MangaAllInOneController(extras))) + } else { + router.pushController(RouterTransaction.with(MangaController(extras))) + } } SHORTCUT_DOWNLOADS -> { if (router.backstackSize > 1) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOneController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOneController.kt new file mode 100644 index 000000000..49208389c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOneController.kt @@ -0,0 +1,1318 @@ +package eu.kanade.tachiyomi.ui.manga + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.google.android.material.snackbar.Snackbar +import com.google.gson.Gson +import com.jakewharton.rxrelay.BehaviorRelay +import com.jakewharton.rxrelay.PublishRelay +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.model.Download +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.MangaAllInOneControllerBinding +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.source.online.all.MergedSource +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.source.SourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog +import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight +import eu.kanade.tachiyomi.ui.manga.chapter.ChapterHolder +import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem +import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersAdapter +import eu.kanade.tachiyomi.ui.manga.chapter.DeleteChaptersDialog +import eu.kanade.tachiyomi.ui.manga.chapter.DownloadCustomChaptersDialog +import eu.kanade.tachiyomi.ui.migration.manga.design.PreMigrationController +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.copyToClipboard +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.getCoordinates +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.shrinkOnScroll +import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.visible +import eu.kanade.tachiyomi.util.view.visibleIf +import exh.EH_SOURCE_ID +import exh.EXH_SOURCE_ID +import exh.MERGED_SOURCE_ID +import exh.util.setChipsExtended +import java.text.DateFormat +import java.text.DecimalFormat +import java.util.Date +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import reactivecircus.flowbinding.android.view.clicks +import reactivecircus.flowbinding.android.view.longClicks +import reactivecircus.flowbinding.swiperefreshlayout.refreshes +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +/** + * Fragment that shows manga information. + * Uses R.layout.manga_info_controller. + * UI related actions should be called from here. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MangaAllInOneController : + NucleusController, + ChangeMangaCategoriesDialog.Listener, + CoroutineScope, + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + DownloadCustomChaptersDialog.Listener, + DeleteChaptersDialog.Listener { + + constructor(manga: Manga?, fromSource: Boolean = false, smartSearchConfig: SourceController.SmartSearchConfig? = null, update: Boolean = false) : super( + Bundle().apply { + putLong(MANGA_EXTRA, manga?.id ?: 0) + putBoolean(FROM_SOURCE_EXTRA, fromSource) + putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig) + putBoolean(UPDATE_EXTRA, update) + } + ) { + this.manga = manga + if (manga != null) { + source = Injekt.get().getOrStub(manga.source) + } + } + + // EXH --> + constructor(redirect: MangaAllInOnePresenter.EXHRedirect) : super( + Bundle().apply { + putLong(MANGA_EXTRA, redirect.manga.id!!) + putBoolean(UPDATE_EXTRA, redirect.update) + } + ) { + this.manga = redirect.manga + if (manga != null) { + source = Injekt.get().getOrStub(redirect.manga.source) + } + } + // EXH <-- + + constructor(mangaId: Long) : this( + Injekt.get().getManga(mangaId).executeAsBlocking() + ) + + @Suppress("unused") + constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) + + var manga: Manga? = null + private set + + var source: Source? = null + private set + + private val preferences: PreferencesHelper by injectLazy() + + /** + * Adapter containing a list of chapters. + */ + private var adapter: ChaptersAdapter? = null + + /** + * Action mode for multiple selection. + */ + private var actionMode: ActionMode? = null + + /** + * Selected items. Used to restore selections after a rotation. + */ + private val selectedItems = mutableSetOf() + + private var lastClickPosition = -1 + + private val dateFormat: DateFormat by lazy { + preferences.dateFormat() + } + + private var initialLoad: Boolean = true + + // EXH --> + private var lastMangaThumbnail: String? = null + + // EXH --> + val smartSearchConfig: SourceController.SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG_EXTRA) + // EXH <-- + + override val coroutineContext: CoroutineContext = Job() + Dispatchers.Main + + private val gson: Gson by injectLazy() + + private val sourceManager: SourceManager by injectLazy() + // EXH <-- + + val lastUpdateRelay: BehaviorRelay = BehaviorRelay.create() + + val chapterCountRelay: BehaviorRelay = BehaviorRelay.create() + + val mangaFavoriteRelay: PublishRelay = PublishRelay.create() + + val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) + + var update = args.getBoolean(UPDATE_EXTRA, false) + + init { + setHasOptionsMenu(true) + setOptionsMenuHidden(true) + } + + override fun getTitle(): String? { + return manga?.title + } + + override fun createPresenter(): MangaAllInOnePresenter { + // val ctrl = parentController as MangaController + return MangaAllInOnePresenter( + manga!!, source!!, + chapterCountRelay, lastUpdateRelay, mangaFavoriteRelay, smartSearchConfig + ) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = MangaAllInOneControllerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Setting this via XML doesn't work + 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) + } + + if (presenter.manga.favorite) { + binding.btnMigrate.visible() + binding.btnSmartSearch.visible() + } + + binding.btnMigrate.clicks() + .onEach { + PreMigrationController.navigateToMigration( + preferences.skipPreMigration().get(), + router, + listOf(presenter.manga.id!!) + ) + } + .launchIn(scope) + + binding.btnSmartSearch.clicks() + .onEach { openSmartSearch() } + .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.mangaArtist.longClicks() + .onEach { + activity?.copyToClipboard(binding.mangaArtistLabel.text.toString(), binding.mangaArtist.text.toString()) + } + .launchIn(scope) + + binding.mangaArtist.clicks() + .onEach { + var text = binding.mangaArtist.text.toString() + if (isEHentaiBasedSource()) { + text = wrapTag("artist", text) + } + performGlobalSearch(text) + } + .launchIn(scope) + + binding.mangaAuthor.longClicks() + .onEach { + // EXH Special case E-Hentai/ExHentai to ignore author field (unused) + if (!isEHentaiBasedSource()) { + activity?.copyToClipboard(binding.mangaAuthor.text.toString(), binding.mangaAuthor.text.toString()) + } + } + .launchIn(scope) + + binding.mangaAuthor.clicks() + .onEach { + // EXH Special case E-Hentai/ExHentai to ignore author field (unused) + if (!isEHentaiBasedSource()) { + 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) + + // EXH --> + if (smartSearchConfig == null) { + binding.recommendBtn.visible() + binding.recommendBtn.clicks() + .onEach { openRecommends() } + .launchIn(scope) + } + smartSearchConfig?.let { smartSearchConfig -> + if (smartSearchConfig.origMangaId != null) { binding.mergeBtn.visible() } + binding.mergeBtn.clicks() + .onEach { + // Init presenter here to avoid threading issues + presenter + + launch { + try { + val mergedManga = withContext(Dispatchers.IO + NonCancellable) { + presenter.smartSearchMerge(presenter.manga, smartSearchConfig.origMangaId!!) + } + + router?.pushController( + MangaAllInOneController( + mergedManga, + true, + update = true + ).withFadeTransaction() + ) + applicationContext?.toast("Manga merged!") + } catch (e: Exception) { + if (e is CancellationException) throw e + else { + applicationContext?.toast("Failed to merge manga: ${e.message}") + } + } + } + } + .launchIn(scope) + } + // EXH <-- + + if (manga == null || source == null) return + + // Init RecyclerView and adapter + adapter = ChaptersAdapter(this, view.context) + + binding.recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.recycler.setHasFixedSize(true) + adapter?.fastScroller = binding.fastScroller + + binding.fab.clicks() + .onEach { + val item = presenter.getNextUnreadChapter() + if (item != null) { + // Create animation listener + val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator?) { + openChapter(item.chapter, true) + } + } + + // Get coordinates and start animation + val coordinates = binding.fab.getCoordinates() + if (!binding.revealView.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { + openChapter(item.chapter) + } + } else { + view.context.toast(R.string.no_next_chapter) + } + } + .launchIn(scope) + + binding.fab.shrinkOnScroll(binding.recycler) + + binding.actionToolbar.offsetAppbarHeight(activity!!) + binding.fab.offsetAppbarHeight(activity!!) + } + + // EXH --> + private fun openSmartSearch() { + val smartSearchConfig = SourceController.SmartSearchConfig(presenter.manga.title, presenter.manga.id!!) + + parentController?.router?.pushController( + SourceController( + Bundle().apply { + putParcelable(SourceController.SMART_SEARCH_CONFIG, smartSearchConfig) + } + ).withFadeTransaction() + ) + } + // EXH <-- + + // AZ --> + private fun openRecommends() { + val recommendsConfig = BrowseSourceController.RecommendsConfig(presenter.manga.title, presenter.manga.source) + + parentController?.router?.pushController( + BrowseSourceController( + Bundle().apply { + putParcelable(BrowseSourceController.RECOMMENDS_CONFIG, recommendsConfig) + } + ).withFadeTransaction() + ) + } + // AZ <-- + + /** + * 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 artist TextView. + binding.mangaArtist.text = if (manga.artist.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.artist + } + + // Update author TextView. + binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.author + } + + // If manga source is known update source TextView. + val mangaSource = source?.toString() + with(binding.mangaSource) { + // EXH --> + if (mangaSource == null) { + text = view.context.getString(R.string.unknown) + } else if (source.id == MERGED_SOURCE_ID) { + text = MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map { + sourceManager.getOrStub(it.source).toString() + }.distinct().joinToString() + } else { + text = mangaSource + setOnClickListener { + val sourceManager = Injekt.get() + performSearch(sourceManager.getOrStub(source.id).name) + } + } + // EXH <-- + } + + // EXH --> + if (source?.id == MERGED_SOURCE_ID) { + binding.mangaSourceLabel.text = "Sources" + } else { + binding.mangaSourceLabel.setText(R.string.manga_info_source_label) + } + // EXH <-- + + // 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.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source) + binding.mangaGenresTagsFullChips.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source) + } 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() } + if (isNowFavorite) { + binding.btnSmartSearch.visible() + binding.btnMigrate.visible() + } else { + binding.btnSmartSearch.gone() + binding.btnMigrate.gone() + } + } + + 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() { + fetchChaptersFromSource() + } + + fun onFetchChaptersDone() { + 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) + } + + /** + * 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()) + } + + // --> EH + private fun wrapTag(namespace: String, tag: String) = + if (tag.contains(' ')) { + "$namespace:\"$tag$\"" + } else { + "$namespace:$tag$" + } + + private fun parseTag(tag: String) = tag.substringBefore(':').trim() to tag.substringAfter(':').trim() + + private fun isEHentaiBasedSource(): Boolean { + val sourceId = presenter.source.id + return sourceId == EH_SOURCE_ID || + sourceId == EXH_SOURCE_ID + } + // <-- EH + + /** + * 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) + } + } + } + + // CHAPTER FUNCTIONS START HERE + override fun onPrepareOptionsMenu(menu: Menu) { + // Initialize menu items. + val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return + val menuFilterUnread = menu.findItem(R.id.action_filter_unread) + val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) + val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked) + val menuFilterEmpty = menu.findItem(R.id.action_filter_empty) + + // Set correct checkbox values. + menuFilterRead.isChecked = presenter.onlyRead() + menuFilterUnread.isChecked = presenter.onlyUnread() + menuFilterDownloaded.isChecked = presenter.onlyDownloaded() + menuFilterDownloaded.isEnabled = !presenter.forceDownloaded() + menuFilterBookmarked.isChecked = presenter.onlyBookmarked() + + val filterSet = presenter.onlyRead() || presenter.onlyUnread() || presenter.onlyDownloaded() || presenter.onlyBookmarked() + + if (filterSet) { + val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) + DrawableCompat.setTint(menu.findItem(R.id.action_filter).icon, filterColor) + } + + // Only show remove filter option if there's a filter set. + menuFilterEmpty.isVisible = filterSet + + // Disable unread filter option if read filter is enabled. + if (presenter.onlyRead()) { + menuFilterUnread.isEnabled = false + } + // Disable read filter option if unread filter is enabled. + if (presenter.onlyUnread()) { + menuFilterRead.isEnabled = false + } + + // Display mode submenu + if (presenter.manga.displayMode == Manga.DISPLAY_NAME) { + menu.findItem(R.id.display_title).isChecked = true + } else { + menu.findItem(R.id.display_chapter_number).isChecked = true + } + + // Sorting mode submenu + if (presenter.manga.sorting == Manga.SORTING_SOURCE) { + menu.findItem(R.id.sort_by_source).isChecked = true + } else { + menu.findItem(R.id.sort_by_number).isChecked = true + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.display_title -> { + item.isChecked = true + setDisplayMode(Manga.DISPLAY_NAME) + } + R.id.display_chapter_number -> { + item.isChecked = true + setDisplayMode(Manga.DISPLAY_NUMBER) + } + + R.id.sort_by_source -> { + item.isChecked = true + presenter.setSorting(Manga.SORTING_SOURCE) + } + R.id.sort_by_number -> { + item.isChecked = true + presenter.setSorting(Manga.SORTING_NUMBER) + } + + R.id.download_next, R.id.download_next_5, R.id.download_next_10, + R.id.download_custom, R.id.download_unread, R.id.download_all + -> downloadChapters(item.itemId) + + R.id.action_filter_unread -> { + item.isChecked = !item.isChecked + presenter.setUnreadFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_read -> { + item.isChecked = !item.isChecked + presenter.setReadFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_downloaded -> { + item.isChecked = !item.isChecked + presenter.setDownloadedFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_bookmarked -> { + item.isChecked = !item.isChecked + presenter.setBookmarkedFilter(item.isChecked) + activity?.invalidateOptionsMenu() + } + R.id.action_filter_empty -> { + presenter.removeFilters() + activity?.invalidateOptionsMenu() + } + R.id.action_sort -> presenter.revertSortOrder() + } + return super.onOptionsItemSelected(item) + } + + fun onNextChapters(chapters: List) { + // If the list is empty, fetch chapters from source if the conditions are met + // We use presenter chapters instead because they are always unfiltered + if (presenter.chapters.isEmpty()) { + initialFetchChapters() + } + + if (update || + // Auto-update old format galleries + ( + (presenter.manga.source == EH_SOURCE_ID || presenter.manga.source == EXH_SOURCE_ID) && + chapters.size == 1 && chapters.first().date_upload == 0L + ) + ) { + update = false + fetchChaptersFromSource() + } + + val adapter = adapter ?: return + adapter.updateDataSet(chapters) + + if (selectedItems.isNotEmpty()) { + adapter.clearSelection() // we need to start from a clean state, index may have changed + createActionModeIfNeeded() + selectedItems.forEach { item -> + val position = adapter.indexOf(item) + if (position != -1 && !adapter.isSelected(position)) { + adapter.toggleSelection(position) + } + } + actionMode?.invalidate() + } + } + + private fun initialFetchChapters() { + // Only fetch if this view is from the catalog and it hasn't requested previously + if (fromSource && !presenter.hasRequested) { + fetchChaptersFromSource() + } + } + + private fun fetchChaptersFromSource() { + presenter.fetchChaptersFromSource() + } + + fun onFetchChaptersError(error: Throwable) { + onFetchChaptersDone() + activity?.toast(error.message) + } + + fun onChapterStatusChange(download: Download) { + getHolder(download.chapter)?.notifyStatus(download.status) + } + + private fun getHolder(chapter: Chapter): ChapterHolder? { + return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder + } + + fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { + val activity = activity ?: return + val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) + if (hasAnimation) { + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + startActivity(intent) + } + + override fun onItemClick(view: View?, position: Int): Boolean { + val adapter = adapter ?: return false + val item = adapter.getItem(position) ?: return false + return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + lastClickPosition = position + toggleSelection(position) + true + } else { + openChapter(item.chapter) + false + } + } + + override fun onItemLongClick(position: Int) { + createActionModeIfNeeded() + when { + lastClickPosition == -1 -> setSelection(position) + lastClickPosition > position -> + for (i in position until lastClickPosition) + setSelection(i) + lastClickPosition < position -> + for (i in lastClickPosition + 1..position) + setSelection(i) + else -> setSelection(position) + } + lastClickPosition = position + adapter?.notifyDataSetChanged() + } + + // SELECTIONS & ACTION MODE + + private fun toggleSelection(position: Int) { + val adapter = adapter ?: return + val item = adapter.getItem(position) ?: return + adapter.toggleSelection(position) + adapter.notifyDataSetChanged() + if (adapter.isSelected(position)) { + selectedItems.add(item) + } else { + selectedItems.remove(item) + } + actionMode?.invalidate() + } + + private fun setSelection(position: Int) { + val adapter = adapter ?: return + val item = adapter.getItem(position) ?: return + if (!adapter.isSelected(position)) { + adapter.toggleSelection(position) + selectedItems.add(item) + actionMode?.invalidate() + } + } + + private fun getSelectedChapters(): List { + val adapter = adapter ?: return emptyList() + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } + } + + private fun createActionModeIfNeeded() { + if (actionMode == null) { + actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) + binding.actionToolbar.show( + actionMode!!, + R.menu.chapter_selection + ) { onActionItemClicked(it!!) } + } + } + + private fun destroyActionModeIfNeeded() { + lastClickPosition = -1 + actionMode?.finish() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.generic_selection, menu) + adapter?.mode = SelectableAdapter.Mode.MULTI + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val count = adapter?.selectedItemCount ?: 0 + if (count == 0) { + // Destroy action mode if there are no items selected. + destroyActionModeIfNeeded() + } else { + mode.title = count.toString() + + val chapters = getSelectedChapters() + binding.actionToolbar.findItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark } + binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark } + binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read } + binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read } + + // Hide FAB to avoid interfering with the bottom action toolbar + // binding.fab.hide() + binding.fab.gone() + } + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return onActionItemClicked(item) + } + + private fun onActionItemClicked(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_select_all -> selectAll() + R.id.action_select_inverse -> selectInverse() + R.id.action_download -> downloadChapters(getSelectedChapters()) + R.id.action_delete -> showDeleteChaptersConfirmationDialog() + R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true) + R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false) + R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) + R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) + R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters()) + else -> return false + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + binding.actionToolbar.hide() + adapter?.mode = SelectableAdapter.Mode.SINGLE + adapter?.clearSelection() + selectedItems.clear() + actionMode = null + + // TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton] + // fails to show up properly + // binding.fab.show() + binding.fab.visible() + } + + override fun onDetach(view: View) { + destroyActionModeIfNeeded() + super.onDetach(view) + } + + // SELECTION MODE ACTIONS + + private fun selectAll() { + val adapter = adapter ?: return + adapter.selectAll() + selectedItems.addAll(adapter.items) + actionMode?.invalidate() + } + + private fun selectInverse() { + val adapter = adapter ?: return + + selectedItems.clear() + for (i in 0..adapter.itemCount) { + adapter.toggleSelection(i) + } + selectedItems.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) }) + + actionMode?.invalidate() + adapter.notifyDataSetChanged() + } + + private fun markAsRead(chapters: List) { + presenter.markChaptersRead(chapters, true) + if (presenter.preferences.removeAfterMarkedAsRead()) { + deleteChapters(chapters) + } + } + + private fun markAsUnread(chapters: List) { + presenter.markChaptersRead(chapters, false) + } + + private fun downloadChapters(chapters: List) { + val view = view + presenter.downloadChapters(chapters) + if (view != null && !presenter.manga.favorite) { + binding.recycler.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.action_add) { + presenter.addToLibrary() + } + } + } + } + + private fun showDeleteChaptersConfirmationDialog() { + DeleteChaptersDialog(this).showDialog(router) + } + + override fun deleteChapters() { + deleteChapters(getSelectedChapters()) + } + + private fun markPreviousAsRead(chapters: List) { + val adapter = adapter ?: return + val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items + val chapterPos = prevChapters.indexOf(chapters.last()) + if (chapterPos != -1) { + markAsRead(prevChapters.take(chapterPos)) + } + } + + private fun bookmarkChapters(chapters: List, bookmarked: Boolean) { + presenter.bookmarkChapters(chapters, bookmarked) + } + + fun deleteChapters(chapters: List) { + if (chapters.isEmpty()) return + + presenter.deleteChapters(chapters) + } + + fun onChaptersDeleted(chapters: List) { + // this is needed so the downloaded text gets removed from the item + chapters.forEach { + adapter?.updateItem(it) + } + adapter?.notifyDataSetChanged() + } + + fun onChaptersDeletedError(error: Throwable) { + Timber.e(error) + } + + // OVERFLOW MENU DIALOGS + + private fun setDisplayMode(id: Int) { + presenter.setDisplayMode(id) + adapter?.notifyDataSetChanged() + } + + private fun getUnreadChaptersSorted() = presenter.chapters + .filter { !it.read && it.status == Download.NOT_DOWNLOADED } + .distinctBy { it.name } + .sortedByDescending { it.source_order } + + private fun downloadChapters(choice: Int) { + val chaptersToDownload = when (choice) { + R.id.download_next -> getUnreadChaptersSorted().take(1) + R.id.download_next_5 -> getUnreadChaptersSorted().take(5) + R.id.download_next_10 -> getUnreadChaptersSorted().take(10) + R.id.download_custom -> { + showCustomDownloadDialog() + return + } + R.id.download_unread -> presenter.chapters.filter { !it.read } + R.id.download_all -> presenter.chapters + else -> emptyList() + } + if (chaptersToDownload.isNotEmpty()) { + downloadChapters(chaptersToDownload) + } + } + + private fun showCustomDownloadDialog() { + DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) + } + + override fun downloadCustomChapters(amount: Int) { + val chaptersToDownload = getUnreadChaptersSorted().take(amount) + if (chaptersToDownload.isNotEmpty()) { + downloadChapters(chaptersToDownload) + } + } + + companion object { + // EXH --> + const val UPDATE_EXTRA = "update" + const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig" + // EXH <-- + const val FROM_SOURCE_EXTRA = "from_source" + const val MANGA_EXTRA = "manga" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOnePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOnePresenter.kt new file mode 100644 index 000000000..2c65c8f04 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaAllInOnePresenter.kt @@ -0,0 +1,697 @@ +package eu.kanade.tachiyomi.ui.manga + +import android.os.Bundle +import com.google.gson.Gson +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 +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.online.all.MergedSource +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.browse.source.SourceController +import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed +import eu.kanade.tachiyomi.util.prepUpdateCover +import eu.kanade.tachiyomi.util.removeCovers +import exh.EH_SOURCE_ID +import exh.EXH_SOURCE_ID +import exh.MERGED_SOURCE_ID +import exh.debug.DebugToggles +import exh.eh.EHentaiUpdateHelper +import exh.util.await +import java.util.Date +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of MangaInfoFragment. + * Contains information and data for fragment. + * Observable updates should be called from here. + */ +class MangaAllInOnePresenter( + val manga: Manga, + val source: Source, + private val chapterCountRelay: BehaviorRelay, + private val lastUpdateRelay: BehaviorRelay, + private val mangaFavoriteRelay: PublishRelay, + val smartSearchConfig: SourceController.SmartSearchConfig?, + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val gson: Gson = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { + + /** + * List of chapters of the manga. It's always unfiltered and unsorted. + */ + var chapters: List = emptyList() + private set + + /** + * Subject of list of chapters to allow updating the view without going to DB. + */ + val chaptersRelay: PublishRelay> + by lazy { PublishRelay.create>() } + + /** + * Whether the chapter list has been requested to the source. + */ + var hasRequested = false + private set + + /** + * Subscription to retrieve the new list of chapters from the source. + */ + private var fetchChaptersSubscription: Subscription? = null + + /** + * Subscription to observe download status changes. + */ + private var observeDownloadsSubscription: Subscription? = null + + // EXH --> + private val updateHelper: EHentaiUpdateHelper by injectLazy() + + val redirectUserRelay = BehaviorRelay.create() + + data class EXHRedirect(val manga: Manga, val update: Boolean) + // EXH <-- + + /** + * Subscription to send the manga to the view. + */ + private var viewMangaSubscription: Subscription? = null + + /** + * 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(MangaAllInOneController::setChapterCount) + + // Prepare the relay. + chaptersRelay.flatMap { applyChapterFilters(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(MangaAllInOneController::onNextChapters) { _, error -> Timber.e(error) } + + // Add the subscription that retrieves the chapters from the database, keeps subscribed to + // changes, and sends the list of chapters to the relay. + add( + db.getChapters(manga).asRxObservable() + .map { chapters -> + // Convert every chapter to a model. + chapters.map { it.toModel() } + } + .doOnNext { chapters -> + // Find downloaded chapters + setDownloadedChapters(chapters) + + // Store the last emission + this.chapters = chapters + + // 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 + ) + ) + // EXH --> + if (chapters.isNotEmpty() && + (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) && + DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled + ) { + // Check for gallery in library and accept manga with lowest id + // Find chapters sharing same root + add( + updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters) + .subscribeOn(Schedulers.io()) + .subscribe { (acceptedChain, _) -> + // Redirect if we are not the accepted root + if (manga.id != acceptedChain.manga.id) { + // Update if any of our chapters are not in accepted manga's chapters + val ourChapterUrls = chapters.map { it.url }.toSet() + val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet() + val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty() + redirectUserRelay.call( + MangaAllInOnePresenter.EXHRedirect( + acceptedChain.manga, + update + ) + ) + } + } + ) + } + // EXH <-- + } + .subscribe { chaptersRelay.call(it) } + ) + + // Update favorite status + mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) + .subscribe { setFavorite(it) } + .apply { add(this) } + + // update last update date + lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(MangaAllInOneController::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 -> + if (manualFetch || manga.thumbnail_url != networkManga.thumbnail_url) { + manga.prepUpdateCover(coverCache) + } + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + manga + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> + view.onFetchMangaDone() + }, + MangaAllInOneController::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)) + } + /* + suspend fun recommendationView(manga: Manga): Manga { + val title = manga.title + val source = manga.source + + }*/ + suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga { + val originalManga = db.getManga(originalMangaId).await() + ?: throw IllegalArgumentException("Unknown manga ID: $originalMangaId") + val toInsert = if (originalManga.source == MERGED_SOURCE_ID) { + originalManga.apply { + val originalChildren = MergedSource.MangaConfig.readFromUrl(gson, url).children + if (originalChildren.any { it.source == manga.source && it.url == manga.url }) { + throw IllegalArgumentException("This manga is already merged with the current manga!") + } + + url = MergedSource.MangaConfig( + originalChildren + MergedSource.MangaSource( + manga.source, + manga.url + ) + ).writeAsUrl(gson) + } + } else { + val newMangaConfig = MergedSource.MangaConfig( + listOf( + MergedSource.MangaSource( + originalManga.source, + originalManga.url + ), + MergedSource.MangaSource( + manga.source, + manga.url + ) + ) + ) + Manga.create(newMangaConfig.writeAsUrl(gson), originalManga.title, MERGED_SOURCE_ID).apply { + copyFrom(originalManga) + favorite = true + last_update = originalManga.last_update + viewer = originalManga.viewer + chapter_flags = originalManga.chapter_flags + sorting = Manga.SORTING_NUMBER + } + } + + // Note that if the manga are merged in a different order, this won't trigger, but I don't care lol + val existingManga = db.getManga(toInsert.url, toInsert.source).await() + if (existingManga != null) { + withContext(NonCancellable) { + if (toInsert.id != null) { + db.deleteManga(toInsert).await() + } + } + + return existingManga + } + + // Reload chapters immediately + toInsert.initialized = false + + val newId = db.insertManga(toInsert).await().insertedId() + if (newId != null) toInsert.id = newId + + return toInsert + } + + 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(MangaAllInOneController::onChapterStatusChange) { _, error -> + Timber.e(error) + } + } + + /** + * Converts a chapter from the database to an extended model, allowing to store new fields. + */ + private fun Chapter.toModel(): ChapterItem { + // Create the model object. + val model = ChapterItem(this, manga) + + // Find an active download for this chapter. + val download = downloadManager.queue.find { it.chapter.id == id } + + if (download != null) { + // If there's an active download, assign it. + model.download = download + } + return model + } + + /** + * Finds and assigns the list of downloaded chapters. + * + * @param chapters the list of chapter from the database. + */ + private fun setDownloadedChapters(chapters: List) { + for (chapter in chapters) { + if (downloadManager.isChapterDownloaded(chapter, manga)) { + chapter.status = Download.DOWNLOADED + } + } + } + + /** + * Requests an updated list of chapters from the source. + */ + fun fetchChaptersFromSource() { + hasRequested = true + + if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return + fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } + .subscribeOn(Schedulers.io()) + .map { syncChaptersWithSource(db, it, manga, source) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> + view.onFetchChaptersDone() + }, + MangaAllInOneController::onFetchChaptersError + ) + } + + /** + * Updates the UI after applying the filters. + */ + private fun refreshChapters() { + chaptersRelay.call(chapters) + } + + /** + * Applies the view filters to the list of chapters obtained from the database. + * @param chapters the list of chapters from the database + * @return an observable of the list of chapters filtered and sorted. + */ + private fun applyChapterFilters(chapters: List): Observable> { + var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) + if (onlyUnread()) { + observable = observable.filter { !it.read } + } else if (onlyRead()) { + observable = observable.filter { it.read } + } + if (onlyDownloaded()) { + observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } + } + if (onlyBookmarked()) { + observable = observable.filter { it.bookmark } + } + val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { + Manga.SORTING_SOURCE -> when (sortDescending()) { + true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } + false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } + } + Manga.SORTING_NUMBER -> when (sortDescending()) { + true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } + false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } + } + else -> throw NotImplementedError("Unimplemented sorting method") + } + return observable.toSortedList(sortFunction) + } + + /** + * Called when a download for the active manga changes status. + * @param download the download whose status changed. + */ + fun onDownloadStatusChange(download: Download) { + // Assign the download to the model object. + if (download.status == Download.QUEUE) { + chapters.find { it.id == download.chapter.id }?.let { + if (it.download == null) { + it.download = download + } + } + } + + // Force UI update if downloaded filter active and download finished. + if (onlyDownloaded() && download.status == Download.DOWNLOADED) { + refreshChapters() + } + } + + /** + * Returns the next unread chapter or null if everything is read. + */ + fun getNextUnreadChapter(): ChapterItem? { + return chapters.sortedByDescending { it.source_order }.find { !it.read } + } + + /** + * Mark the selected chapter list as read/unread. + * @param selectedChapters the list of selected chapters. + * @param read whether to mark chapters as read or unread. + */ + fun markChaptersRead(selectedChapters: List, read: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.read = read + if (!read /* --> EH */ && !preferences + .eh_preserveReadingPosition() + .get() /* <-- EH */ + ) { + chapter.last_page_read = 0 + } + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Downloads the given list of chapters with the manager. + * @param chapters the list of chapters to download. + */ + fun downloadChapters(chapters: List) { + downloadManager.downloadChapters(manga, chapters) + } + + /** + * Bookmarks the given list of chapters. + * @param selectedChapters the list of chapters to bookmark. + */ + fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.bookmark = bookmarked + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Deletes the given list of chapter. + * @param chapters the list of chapters to delete. + */ + fun deleteChapters(chapters: List) { + Observable.just(chapters) + .doOnNext { deleteChaptersInternal(chapters) } + .doOnNext { if (onlyDownloaded()) refreshChapters() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> + view.onChaptersDeleted(chapters) + }, + MangaAllInOneController::onChaptersDeletedError + ) + } + + /** + * Deletes a list of chapters from disk. This method is called in a background thread. + * @param chapters the chapters to delete. + */ + private fun deleteChaptersInternal(chapters: List) { + downloadManager.deleteChapters(chapters, manga, source) + chapters.forEach { + it.status = Download.NOT_DOWNLOADED + it.download = null + } + } + + /** + * Reverses the sorting and requests an UI update. + */ + fun revertSortOrder() { + manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyUnread whether to display only unread chapters or all chapters. + */ + fun setUnreadFilter(onlyUnread: Boolean) { + manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyRead whether to display only read chapters or all chapters. + */ + fun setReadFilter(onlyRead: Boolean) { + manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the download filter and requests an UI update. + * @param onlyDownloaded whether to display only downloaded chapters or all chapters. + */ + fun setDownloadedFilter(onlyDownloaded: Boolean) { + manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the bookmark filter and requests an UI update. + * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. + */ + fun setBookmarkedFilter(onlyBookmarked: Boolean) { + manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Removes all filters and requests an UI update. + */ + fun removeFilters() { + manga.readFilter = Manga.SHOW_ALL + manga.downloadedFilter = Manga.SHOW_ALL + manga.bookmarkedFilter = Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Adds manga to library + */ + fun addToLibrary() { + mangaFavoriteRelay.call(true) + } + + /** + * Sets the active display mode. + * @param mode the mode to set. + */ + fun setDisplayMode(mode: Int) { + manga.displayMode = mode + db.updateFlags(manga).executeAsBlocking() + } + + /** + * Sets the sorting method and requests an UI update. + * @param sort the sorting mode. + */ + fun setSorting(sort: Int) { + manga.sorting = sort + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Whether downloaded only mode is enabled. + */ + fun forceDownloaded(): Boolean { + return manga.favorite && preferences.downloadedOnly().get() + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyDownloaded(): Boolean { + return forceDownloaded() || manga.downloadedFilter == Manga.SHOW_DOWNLOADED + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyBookmarked(): Boolean { + return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED + } + + /** + * Whether the display only unread filter is enabled. + */ + fun onlyUnread(): Boolean { + return manga.readFilter == Manga.SHOW_UNREAD + } + + /** + * Whether the display only read filter is enabled. + */ + fun onlyRead(): Boolean { + return manga.readFilter == Manga.SHOW_READ + } + + /** + * Whether the sorting method is descending or ascending. + */ + fun sortDescending(): Boolean { + return manga.sortDescending() + } +} 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 5de867417..e44f25cb9 100755 --- 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: Any, context: Context ) : FlexibleAdapter(null, controller, true) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt index f83ad6ec3..7787d3865 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationListController.kt @@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.migration.MigrationMangaDialog import eu.kanade.tachiyomi.ui.migration.SearchController @@ -397,7 +398,7 @@ class MigrationListController(bundle: Bundle? = null) : private fun navigateOut() { if (migratingManga?.size == 1) { launchUI { - val hasDetails = router.backstack.any { it.controller() is MangaController } + val hasDetails = router.backstack.any { it.controller() is MangaController } || router.backstack.any { it.controller() is MangaAllInOneController } if (hasDetails) { val manga = migratingManga?.firstOrNull()?.searchResult?.get()?.let { db.getManga(it).executeOnIO() @@ -405,9 +406,10 @@ class MigrationListController(bundle: Bundle? = null) : if (manga != null) { val newStack = router.backstack.filter { it.controller() !is MangaController && + it.controller() !is MangaAllInOneController && it.controller() !is MigrationListController && it.controller() !is PreMigrationController - } + MangaController(manga).withFadeTransaction() + } + MangaController(manga).withFadeTransaction() // TODO MangaAllInOneController router.setBackstack(newStack, FadeChangeHandler()) return@launchUI } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt index 1b7e046db..49d28922b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/manga/process/MigrationProcessHolder.kt @@ -7,10 +7,12 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.system.getResourceColor @@ -23,6 +25,8 @@ import kotlinx.android.synthetic.main.migration_manga_card.view.* import kotlinx.android.synthetic.main.migration_process_item.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy class MigrationProcessHolder( @@ -67,12 +71,21 @@ class MigrationProcessHolder( withContext(Dispatchers.Main) { migration_manga_card_from.attachManga(manga, source) migration_manga_card_from.setOnClickListener { - adapter.controller.router.pushController( - MangaController( - manga, - true - ).withFadeTransaction() - ) + if (Injekt.get().eh_useNewMangaInterface().get()) { + adapter.controller.router.pushController( + MangaAllInOneController( + manga, + true + ).withFadeTransaction() + ) + } else { + adapter.controller.router.pushController( + MangaController( + manga, + true + ).withFadeTransaction() + ) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt index 63191fe1f..f9fd9f9f7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt @@ -13,12 +13,14 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.HistoryControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.toast @@ -26,6 +28,8 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import reactivecircus.flowbinding.appcompat.queryTextChanges +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get /** * Fragment that shows recently read manga. @@ -158,7 +162,11 @@ class HistoryController : override fun onItemClick(position: Int) { val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return - router.pushController(MangaController(manga).withFadeTransaction()) + if (Injekt.get().eh_useNewMangaInterface().get()) { + router.pushController(MangaAllInOneController(manga).withFadeTransaction()) + } else { + router.pushController(MangaController(manga).withFadeTransaction()) + } } override fun removeHistory(manga: Manga, history: History, all: Boolean) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index c4e7ad350..8102293ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController import eu.kanade.tachiyomi.ui.base.controller.NucleusController @@ -24,6 +25,7 @@ import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.notificationManager @@ -33,6 +35,8 @@ import kotlinx.coroutines.flow.onEach import reactivecircus.flowbinding.recyclerview.scrollStateChanges import reactivecircus.flowbinding.swiperefreshlayout.refreshes import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get /** * Fragment that shows recent chapters. @@ -282,7 +286,11 @@ class UpdatesController : } private fun openManga(chapter: UpdatesItem) { - router.pushController(MangaController(chapter.manga).withFadeTransaction()) + if (Injekt.get().eh_useNewMangaInterface().get()) { + router.pushController(MangaAllInOneController(chapter.manga).withFadeTransaction()) + } else { + router.pushController(MangaController(chapter.manga).withFadeTransaction()) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index 36f09c181..5533ea508 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -193,61 +193,31 @@ class SettingsGeneralController : SettingsController() { } } } - // --> EXH - switchPreference { - key = Keys.eh_expandFilters - title = "Expand all search filters by default" - defaultValue = false - } + preferenceCategory { + title = "EH Settings" - switchPreference { - key = Keys.eh_autoSolveCaptchas - title = "Automatically solve captcha" - summary = - "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device." - defaultValue = false - } - - /*preferenceCategory { - title = "Application lock" - - LockPreference(context).apply { - key = "pref_app_lock" // Not persistent so use random key - isPersistent = false - - addPreference(this) - } - - FingerLockPreference(context).apply { - key = "pref_lock_finger" // Not persistent so use random key - isPersistent = false - - addPreference(this) - - // Call after addPreference - dependency = "pref_app_lock" + switchPreference { + key = Keys.eh_expandFilters + title = "Expand all search filters by default" + defaultValue = false } switchPreference { - key = Keys.eh_lock_manually - - title = "Lock manually only" + key = Keys.eh_autoSolveCaptchas + title = "Automatically solve captcha" summary = - "Disable automatic app locking. The app can still be locked manually by long-pressing the three-lines/back button in the top left corner." + "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device." defaultValue = false } + switchPreference { - key = Keys.secureScreen - title = "Enable Secure Screen" - defaultValue = false + key = Keys.eh_use_new_manga_interface + title = "Use New Manga Interface" + summary = "Use new all in one manga interface" + defaultValue = true } - switchPreference { - key = Keys.hideNotificationContent - titleRes = R.string.hide_notification_content - defaultValue = false - } - }*/ + } // <-- EXH } } diff --git a/app/src/main/java/exh/ui/smartsearch/SmartSearchController.kt b/app/src/main/java/exh/ui/smartsearch/SmartSearchController.kt index e8e36e685..66a29f25f 100644 --- a/app/src/main/java/exh/ui/smartsearch/SmartSearchController.kt +++ b/app/src/main/java/exh/ui/smartsearch/SmartSearchController.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.SmartSearchBinding import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.SourceManager @@ -11,6 +12,7 @@ import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.source.SourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CoroutineScope @@ -20,6 +22,8 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy class SmartSearchController(bundle: Bundle? = null) : NucleusController(), CoroutineScope { @@ -59,7 +63,11 @@ class SmartSearchController(bundle: Bundle? = null) : NucleusController().eh_useNewMangaInterface().get()) { + MangaAllInOneController(event.manga, true, smartSearchConfig).withFadeTransaction() + } else { + MangaController(event.manga, true, smartSearchConfig).withFadeTransaction() + } withContext(Dispatchers.Main) { router.replaceTopController(transaction) } diff --git a/app/src/main/res/layout-land/manga_all_in_one_controller.xml b/app/src/main/res/layout-land/manga_all_in_one_controller.xml new file mode 100644 index 000000000..b7aee0cfa --- /dev/null +++ b/app/src/main/res/layout-land/manga_all_in_one_controller.xml @@ -0,0 +1,439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +