MangaController overhaul (#7244)

This commit is contained in:
Ivan Iskandar
2022-06-25 22:03:48 +07:00
committed by GitHub
parent cf7ca5bd28
commit 33a778873a
57 changed files with 3701 additions and 2955 deletions

View File

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.source.model.SChapter
import java.io.Serializable
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
interface Chapter : SChapter, Serializable {
@@ -29,3 +30,21 @@ interface Chapter : SChapter, Serializable {
}
}
}
fun Chapter.toDomainChapter(): DomainChapter? {
if (id == null || manga_id == null) return null
return DomainChapter(
id = id!!,
mangaId = manga_id!!,
read = read,
bookmark = bookmark,
lastPageRead = last_page_read.toLong(),
dateFetch = date_fetch,
sourceOrder = source_order.toLong(),
url = url,
name = name,
dateUpload = date_upload,
chapterNumber = chapter_number,
scanlator = scanlator,
)
}

View File

@@ -12,4 +12,24 @@ class LibraryManga : MangaImpl() {
get() = readCount > 0
var category: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LibraryManga) return false
if (!super.equals(other)) return false
if (unreadCount != other.unreadCount) return false
if (readCount != other.readCount) return false
if (category != other.category) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + unreadCount
result = 31 * result + readCount
result = 31 * result + category
return result
}
}

View File

@@ -121,3 +121,5 @@ fun Source.getNameForMangaInfo(): String {
else -> toString()
}
}
fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource

View File

@@ -57,6 +57,33 @@ open class Page(
statusCallback = f
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Page) return false
if (index != other.index) return false
if (url != other.url) return false
if (imageUrl != other.imageUrl) return false
if (number != other.number) return false
if (status != other.status) return false
if (progress != other.progress) return false
if (statusSubject != other.statusSubject) return false
if (statusCallback != other.statusCallback) return false
return true
}
override fun hashCode(): Int {
var result = index
result = 31 * result + url.hashCode()
result = 31 * result + (imageUrl?.hashCode() ?: 0)
result = 31 * result + status
result = 31 * result + progress
result = 31 * result + (statusSubject?.hashCode() ?: 0)
result = 31 * result + (statusCallback?.hashCode() ?: 0)
return result
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1

View File

@@ -83,7 +83,7 @@ class SearchController(
binding.progress.isVisible = isReplacingManga
if (!isReplacingManga) {
router.popController(this)
if (newManga != null) {
if (newManga?.id != null) {
val newMangaController = RouterTransaction.with(MangaController(newManga.id!!))
if (router.backstack.lastOrNull()?.controller is MangaController) {
// Replace old MangaController

View File

@@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
private lateinit var manga: Manga
constructor(target: T, manga: Manga) : this() {
targetController = target
this.manga = manga
}
@Suppress("DEPRECATION")
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_edit_cover)
.setPositiveButton(R.string.action_edit) { _, _ ->
(targetController as? Listener)?.openMangaCoverPicker(manga)
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.action_delete) { _, _ ->
(targetController as? Listener)?.deleteMangaCover(manga)
}
.create()
}
interface Listener {
fun deleteMangaCover(manga: Manga)
fun openMangaCoverPicker(manga: Manga)
}
}

View File

@@ -420,7 +420,7 @@ class MainActivity : BaseActivity() {
SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false
val fgController = router.backstack.lastOrNull()?.controller as? MangaController
if (fgController?.manga?.id != extras.getLong(MangaController.MANGA_EXTRA)) {
if (fgController?.mangaId != extras.getLong(MangaController.MANGA_EXTRA)) {
router.popToRoot()
setSelectedNavItem(R.id.nav_library)
router.pushController(RouterTransaction.with(MangaController(extras)))
@@ -601,6 +601,9 @@ class MainActivity : BaseActivity() {
}
val isFullComposeController = internalTo is FullComposeController<*>
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
if (!isTablet()) {
// Save lift state
if (isPush) {
@@ -623,17 +626,6 @@ class MainActivity : BaseActivity() {
}
binding.root.isLiftAppBarOnScroll = internalTo !is NoAppBarElevationController
binding.appbar.isVisible = !isFullComposeController
binding.controllerContainer.enableScrollingBehavior(!isFullComposeController)
// TODO: Remove when MangaController is full compose
if (!isFullComposeController) {
binding.appbar.isTransparentWhenNotLifted = internalTo is MangaController
binding.controllerContainer.overlapHeader = internalTo is MangaController
}
} else {
binding.appbar.isVisible = !isFullComposeController
}
}

View File

@@ -1,127 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.text.SpannableStringBuilder
import android.view.View
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.ChaptersItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
import eu.kanade.tachiyomi.util.lang.toRelativeString
import java.util.Date
class ChapterHolder(
view: View,
private val adapter: ChaptersAdapter,
) : BaseChapterHolder(view, adapter) {
private val binding = ChaptersItemBinding.bind(view)
init {
binding.download.listener = downloadActionListener
}
fun bind(item: ChapterItem, manga: Manga) {
val chapter = item.chapter
binding.chapterTitle.text = when (manga.displayMode) {
Manga.CHAPTER_DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
// TODO: show cleaned name consistently around the app
// else -> cleanChapterName(chapter, manga)
}
// Set correct text color
val chapterTitleColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColor
}
binding.chapterTitle.setTextColor(chapterTitleColor)
val chapterDescriptionColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColorSecondary
}
binding.chapterDescription.setTextColor(chapterDescriptionColor)
binding.bookmarkIcon.isVisible = chapter.bookmark
val descriptions = mutableListOf<CharSequence>()
if (chapter.date_upload > 0) {
descriptions.add(Date(chapter.date_upload).toRelativeString(itemView.context, adapter.relativeTime, adapter.dateFormat))
}
if (!chapter.read && chapter.last_page_read > 0) {
val lastPageRead = buildSpannedString {
color(adapter.readColor) {
append(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1))
}
}
descriptions.add(lastPageRead)
}
if (!chapter.scanlator.isNullOrBlank()) {
descriptions.add(chapter.scanlator!!)
}
if (descriptions.isNotEmpty()) {
binding.chapterDescription.text = descriptions.joinTo(SpannableStringBuilder(), "")
} else {
binding.chapterDescription.text = ""
}
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)
}
private fun cleanChapterName(chapter: Chapter, manga: Manga): String {
return chapter.name
.trim()
.removePrefix(manga.title)
.trim(*CHAPTER_TRIM_CHARS)
}
}
private val CHAPTER_TRIM_CHARS = arrayOf(
// Whitespace
' ',
'\u0009',
'\u000A',
'\u000B',
'\u000C',
'\u000D',
'\u0020',
'\u0085',
'\u00A0',
'\u1680',
'\u2000',
'\u2001',
'\u2002',
'\u2003',
'\u2004',
'\u2005',
'\u2006',
'\u2007',
'\u2008',
'\u2009',
'\u200A',
'\u2028',
'\u2029',
'\u202F',
'\u205F',
'\u3000',
// Separators
'-',
'_',
',',
':',
).toCharArray()

View File

@@ -1,33 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
class ChapterItem(chapter: Chapter, val manga: Manga) :
BaseChapterItem<ChapterHolder, AbstractHeaderItem<FlexibleViewHolder>>(chapter) {
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
return ChapterHolder(view, adapter as ChaptersAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this, manga)
}
}

View File

@@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class ChaptersAdapter(
controller: MangaController,
context: Context,
) : BaseChaptersAdapter<ChapterItem>(controller) {
private val preferences: PreferencesHelper by injectLazy()
var items: List<ChapterItem> = emptyList()
val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
val relativeTime: Int = preferences.relativeTime().get()
val dateFormat: DateFormat = preferences.dateFormat()
override fun updateDataSet(items: List<ChapterItem>?) {
this.items = items ?: emptyList()
super.updateDataSet(items)
}
fun indexOf(item: ChapterItem): Int {
return items.indexOf(item)
}
}

View File

@@ -7,10 +7,11 @@ import android.view.View
import androidx.core.view.isVisible
import com.bluelinelabs.conductor.Router
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.model.toTriStateGroupState
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.ui.manga.MangaPresenter
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
@@ -18,6 +19,9 @@ import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class ChaptersSettingsSheet(
private val router: Router,
@@ -28,7 +32,7 @@ class ChaptersSettingsSheet(
private var manga: Manga? = null
val filters = Filter(context)
private val filters = Filter(context)
private val sort = Sort(context)
private val display = Display(context)
@@ -42,8 +46,14 @@ class ChaptersSettingsSheet(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
scope = MainScope()
// TODO: Listen to changes
updateManga()
scope.launch {
presenter.state
.filterIsInstance<MangaScreenState.Success>()
.collectLatest {
manga = it.manga
getTabViews().forEach { settings -> (settings as Settings).updateView() }
}
}
}
override fun onDetachedFromWindow() {
@@ -63,17 +73,13 @@ class ChaptersSettingsSheet(
R.string.action_display,
)
private fun updateManga() {
manga = presenter.manga.toDomainManga()
}
private fun showPopupMenu(view: View) {
view.popupMenu(
menuRes = R.menu.default_chapter_filter,
onMenuItemClick = {
when (itemId) {
R.id.set_as_default -> {
SetChapterSettingsDialog(presenter.manga).showDialog(router)
SetChapterSettingsDialog(presenter.manga!!.toDbManga()).showDialog(router)
}
}
},
@@ -144,10 +150,6 @@ class ChaptersSettingsSheet(
bookmarked -> presenter.setBookmarkedFilter(newState)
else -> {}
}
// TODO: Remove
updateManga()
updateView()
}
}
}
@@ -202,16 +204,11 @@ class ChaptersSettingsSheet(
override fun onItemClicked(item: Item) {
when (item) {
source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE.toInt())
chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER.toInt())
uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE.toInt())
source -> presenter.setSorting(Manga.CHAPTER_SORTING_SOURCE)
chapterNum -> presenter.setSorting(Manga.CHAPTER_SORTING_NUMBER)
uploadDate -> presenter.setSorting(Manga.CHAPTER_SORTING_UPLOAD_DATE)
else -> throw Exception("Unknown sorting")
}
// TODO: Remove
presenter.reverseSortOrder()
updateManga()
updateView()
}
}
}
@@ -257,14 +254,10 @@ class ChaptersSettingsSheet(
if (item.checked) return
when (item) {
displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME.toInt())
displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER.toInt())
displayTitle -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NAME)
displayChapterNum -> presenter.setDisplayMode(Manga.CHAPTER_DISPLAY_NUMBER)
else -> throw NotImplementedError("Unknown display mode")
}
// TODO: Remove
updateManga()
updateView()
}
}
}

View File

@@ -1,30 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteChaptersDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.deleteChapters()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteChapters()
}
}

View File

@@ -1,69 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaChaptersHeaderBinding
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
class MangaChaptersHeaderAdapter(
private val controller: MangaController,
) :
RecyclerView.Adapter<MangaChaptersHeaderAdapter.HeaderViewHolder>() {
private var numChapters: Int? = null
private var hasActiveFilters: Boolean = false
private lateinit var binding: MangaChaptersHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaChaptersHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
fun setNumChapters(numChapters: Int) {
this.numChapters = numChapters
notifyItemChanged(0, this)
}
fun setHasActiveFilters(hasActiveFilters: Boolean) {
this.hasActiveFilters = hasActiveFilters
notifyItemChanged(0, this)
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.chaptersLabel.text = if (numChapters == null) {
view.context.getString(R.string.chapters)
} else {
view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numChapters!!, numChapters)
}
val filterColor = if (hasActiveFilters) {
view.context.getResourceColor(R.attr.colorFilterActive)
} else {
view.context.getResourceColor(R.attr.colorOnBackground)
}
binding.btnChaptersFilter.drawable.setTint(filterColor)
merge(view.clicks(), binding.btnChaptersFilter.clicks())
.onEach { controller.showSettingsSheet() }
.launchIn(controller.viewScope)
}
}
}

View File

@@ -1,276 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.loadAutoPause
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.injectLazy
class MangaInfoHeaderAdapter(
private val controller: MangaController,
private val fromSource: Boolean,
private val isTablet: Boolean,
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
private val trackManager: TrackManager by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private var manga: Manga = controller.presenter.manga
private var source: Source = controller.presenter.source
private var trackCount: Int = 0
private lateinit var binding: MangaInfoHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
updateCoverPosition()
// Expand manga info if navigated from source listing or explicitly set to
// (e.g. on tablets)
binding.mangaSummarySection.expanded = fromSource || isTablet
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun getItemId(position: Int): Long = hashCode().toLong()
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
update()
}
fun update() {
notifyItemChanged(0, this)
}
fun setTrackingCount(trackCount: Int) {
this.trackCount = trackCount
update()
}
private fun updateCoverPosition() {
if (isTablet) return
val appBarHeight = controller.getMainAppBarHeight()
binding.mangaCover.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin += appBarHeight
}
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(controller.viewScope)
if (controller.presenter.manga.favorite) {
binding.btnFavorite.longClicks()
.onEach { controller.onCategoriesClick() }
.launchIn(controller.viewScope)
}
with(binding.btnTracking) {
if (trackManager.hasLoggedServices()) {
isVisible = true
if (trackCount > 0) {
setIconResource(R.drawable.ic_done_24dp)
text = view.context.resources.getQuantityString(
R.plurals.num_trackers,
trackCount,
trackCount,
)
isActivated = true
} else {
setIconResource(R.drawable.ic_sync_24dp)
text = view.context.getString(R.string.manga_tracking_tab)
isActivated = false
}
clicks()
.onEach { controller.onTrackingClick() }
.launchIn(controller.viewScope)
} else {
isVisible = false
}
}
if (controller.presenter.source is HttpSource) {
binding.btnWebview.isVisible = true
binding.btnWebview.clicks()
.onEach { controller.openMangaInWebView() }
.launchIn(controller.viewScope)
}
binding.mangaFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaAuthor.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaAuthor.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaAuthor.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaArtist.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaArtist.text.toString(),
binding.mangaArtist.text.toString(),
)
}
.launchIn(controller.viewScope)
binding.mangaArtist.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaArtist.text.toString())
}
.launchIn(controller.viewScope)
binding.mangaCover.clicks()
.onEach {
controller.showFullCoverDialog()
}
.launchIn(controller.viewScope)
setMangaInfo()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo() {
// Update full title TextView.
binding.mangaFullTitle.text = manga.title.ifBlank { view.context.getString(R.string.unknown) }
// Update author TextView.
binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
view.context.getString(R.string.unknown_author)
} else {
manga.author
}
// Update artist TextView.
val hasArtist = !manga.artist.isNullOrBlank() && manga.artist != manga.author
binding.mangaArtist.isVisible = hasArtist
if (hasArtist) {
binding.mangaArtist.text = manga.artist
}
// If manga source is known update source TextView.
binding.mangaMissingSourceIcon.isVisible = source is SourceManager.StubSource
with(binding.mangaSource) {
text = source.getNameForMangaInfo()
setOnClickListener {
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
}
// Update manga status.
val (statusDrawable, statusString) = when (manga.status) {
SManga.ONGOING -> R.drawable.ic_status_ongoing_24dp to R.string.ongoing
SManga.COMPLETED -> R.drawable.ic_status_completed_24dp to R.string.completed
SManga.LICENSED -> R.drawable.ic_status_licensed_24dp to R.string.licensed
SManga.PUBLISHING_FINISHED -> R.drawable.ic_done_24dp to R.string.publishing_finished
SManga.CANCELLED -> R.drawable.ic_close_24dp to R.string.cancelled
SManga.ON_HIATUS -> R.drawable.ic_pause_24dp to R.string.on_hiatus
else -> R.drawable.ic_status_unknown_24dp to R.string.unknown
}
binding.mangaStatusIcon.setImageResource(statusDrawable)
binding.mangaStatus.setText(statusString)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if changed.
binding.backdrop.loadAutoPause(manga)
binding.mangaCover.loadAutoPause(manga)
// Manga info section
binding.mangaSummarySection.setTags(manga.getGenres(), controller::performGenreSearch)
binding.mangaSummarySection.description = manga.description
binding.mangaSummarySection.isVisible = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank()
}
/**
* 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.
val (iconResource, stringResource) = when (isFavorite) {
true -> R.drawable.ic_favorite_24dp to R.string.in_library
false -> R.drawable.ic_favorite_border_24dp to R.string.add_to_library
}
binding.btnFavorite.apply {
setIconResource(iconResource)
text = context.getString(stringResource)
isActivated = isFavorite
}
}
}
}

View File

@@ -80,7 +80,7 @@ class TrackSearchDialog : DialogController {
// Do an initial search based on the manga's title
if (savedViewState == null) {
currentlySearched = trackController.presenter.manga.title
currentlySearched = trackController.presenter.manga!!.title
binding!!.titleInput.editText?.append(currentlySearched)
}
search(currentlySearched)

View File

@@ -10,6 +10,7 @@ import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
@@ -25,7 +26,7 @@ import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
class TrackSheet(
val controller: MangaController,
val fragmentManager: FragmentManager,
private val fragmentManager: FragmentManager,
) : BaseBottomSheetDialog(controller.activity!!),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
@@ -74,8 +75,8 @@ class TrackSheet(
override fun onSetClick(position: Int) {
val item = adapter.getItem(position) ?: return
val manga = controller.presenter.manga
val source = controller.presenter.source
val manga = controller.presenter.manga?.toDbManga() ?: return
val source = controller.presenter.source ?: return
if (item.service is EnhancedTrackService) {
if (item.track != null) {

View File

@@ -34,7 +34,7 @@ class HistoryController : ComposeController<HistoryPresenter>(), RootController
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickCover = { history ->
router.pushController(MangaController(history))
router.pushController(MangaController(history.id))
},
onClickResume = { history ->
presenter.getNextChapterForManga(history.mangaId, history.chapterId)

View File

@@ -7,6 +7,7 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
@@ -48,19 +49,18 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
return coverCache.getCustomCoverFile(id).exists()
}
fun Manga.removeCovers(coverCache: CoverCache) {
if (isLocal()) return
fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
if (isLocal()) return 0
cover_last_modified = Date().time
coverCache.deleteFromCache(this, true)
}
fun Manga.updateCoverLastModified(db: DatabaseHelper) {
cover_last_modified = Date().time
db.updateMangaCoverLastModified(this).executeAsBlocking()
return coverCache.deleteFromCache(this, true)
}
fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
return toDomainManga()?.shouldDownloadNewChapters(db, prefs) ?: false
}
fun DomainManga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean {
if (!favorite) return false
// Boolean to determine if user wants to automatically download new chapters.
@@ -75,7 +75,7 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
// Get all categories, else default category (0)
val categoriesForManga =
db.getCategoriesForManga(this).executeAsBlocking()
db.getCategoriesForManga(toDbManga()).executeAsBlocking()
.mapNotNull { it.id }
.takeUnless { it.isEmpty() } ?: listOf(0)

View File

@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.util.chapter
import eu.kanade.domain.manga.interactor.SetMangaChapterFlags
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@@ -34,6 +35,18 @@ object ChapterSettingsHelper {
db.updateChapterFlags(manga).executeAsBlocking()
}
suspend fun applySettingDefaults(mangaId: Long, setMangaChapterFlags: SetMangaChapterFlags) {
setMangaChapterFlags.awaitSetAllFlags(
mangaId = mangaId,
unreadFilter = prefs.filterChapterByRead().toLong(),
downloadedFilter = prefs.filterChapterByDownloaded().toLong(),
bookmarkedFilter = prefs.filterChapterByBookmarked().toLong(),
sortingMode = prefs.sortChapterBySourceOrNumber().toLong(),
sortingDirection = prefs.sortChapterByAscendingOrDescending().toLong(),
displayMode = prefs.displayChapterByNameOrNumber().toLong(),
)
}
/**
* Updates all mangas in library with global Chapter Settings.
*/

View File

@@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.domain.chapter.model.Chapter as DomainChapter
import eu.kanade.domain.manga.model.Manga as DomainManga
fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
return when (manga.sorting) {
@@ -20,3 +23,28 @@ fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending(
else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}")
}
}
fun getChapterSort(
manga: DomainManga,
sortDescending: Boolean = manga.sortDescending(),
): (DomainChapter, DomainChapter) -> Int {
return when (manga.sorting) {
DomainManga.CHAPTER_SORTING_SOURCE -> when (sortDescending) {
true -> { c1, c2 -> c1.sourceOrder.compareTo(c2.sourceOrder) }
false -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
}
DomainManga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
true -> { c1, c2 ->
c2.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c1.chapterNumber.toString())
}
false -> { c1, c2 ->
c1.chapterNumber.toString().compareToCaseInsensitiveNaturalOrder(c2.chapterNumber.toString())
}
}
DomainManga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
true -> { c1, c2 -> c2.dateUpload.compareTo(c1.dateUpload) }
false -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
}

View File

@@ -1,196 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaSummaryBinding
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.setChips
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MangaSummaryView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
private val binding = MangaSummaryBinding.inflate(LayoutInflater.from(context), this, true)
private var animatorSet: AnimatorSet? = null
private var recalculateHeights = false
private var descExpandedHeight = -1
private var descShrunkHeight = -1
var expanded = false
set(value) {
if (field != value) {
field = value
updateExpandState()
}
}
var description: CharSequence? = null
set(value) {
if (field != value) {
field = if (value.isNullOrBlank()) {
context.getString(R.string.unknown)
} else {
value
}
binding.descriptionText.text = field
recalculateHeights = true
doOnNextLayout {
updateExpandState()
}
if (!isInLayout) {
requestLayout()
}
}
}
fun setTags(items: List<String>?, onClick: (item: String) -> Unit) {
listOfNotNull(binding.tagChipsShrunk, binding.tagChipsExpanded).forEach { chips ->
chips.setChips(items, onClick) { tag -> context.copyToClipboard(tag, tag) }
}
}
private fun updateExpandState() = binding.apply {
val initialSetup = descriptionText.maxHeight < 0
val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight
val maxHeightStart = if (initialSetup) maxHeightTarget else descriptionText.maxHeight
val descMaxHeightAnimator = ValueAnimator().apply {
setIntValues(maxHeightStart, maxHeightTarget)
addUpdateListener {
descriptionText.maxHeight = it.animatedValue as Int
}
}
val toggleDrawable = ContextCompat.getDrawable(
context,
if (expanded) R.drawable.anim_caret_up else R.drawable.anim_caret_down,
)
toggleMore.setImageDrawable(toggleDrawable)
var pastHalf = false
val toggleTarget = if (expanded) 1F else 0F
val toggleStart = if (initialSetup) {
toggleTarget
} else {
toggleMore.translationY / toggleMore.height
}
val toggleAnimator = ValueAnimator().apply {
setFloatValues(toggleStart, toggleTarget)
addUpdateListener {
val value = it.animatedValue as Float
toggleMore.translationY = toggleMore.height * value
descriptionScrim.translationY = toggleMore.translationY
toggleMoreScrim.translationY = toggleMore.translationY
tagChipsShrunkContainer.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
tagChipsExpanded.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = toggleMore.translationY.roundToInt()
}
// Update non-animatable objects mid-animation makes it feel less abrupt
if (it.animatedFraction >= 0.5F && !pastHalf) {
pastHalf = true
descriptionText.text = trimWhenNeeded(description)
tagChipsShrunkContainer.scrollX = 0
tagChipsShrunkContainer.isVisible = !expanded
tagChipsExpanded.isVisible = expanded
}
}
}
animatorSet?.cancel()
animatorSet = AnimatorSet().apply {
interpolator = FastOutSlowInInterpolator()
duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong()
playTogether(toggleAnimator, descMaxHeightAnimator)
start()
}
(toggleDrawable as? Animatable)?.start()
}
private fun trimWhenNeeded(text: CharSequence?): CharSequence? {
return if (!expanded) {
text
?.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
?.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
} else {
text
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Wait until parent view has determined the exact width
// because this affect the description line count
val measureWidthFreely = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY
if (!recalculateHeights || measureWidthFreely) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
recalculateHeights = false
// Measure with expanded lines
binding.descriptionText.maxLines = Int.MAX_VALUE
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descExpandedHeight = binding.descriptionText.measuredHeight
// Measure with shrunk lines
binding.descriptionText.maxLines = SHRUNK_DESC_MAX_LINES
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
descShrunkHeight = binding.descriptionText.measuredHeight
}
init {
binding.descriptionText.apply {
// So that 1 line of text won't be hidden by scrim
minLines = DESC_MIN_LINES
setOnLongClickListener {
description?.let {
context.copyToClipboard(
context.getString(R.string.description),
it.toString(),
)
}
true
}
}
arrayOf(
binding.descriptionText,
binding.descriptionScrim,
binding.toggleMoreScrim,
binding.toggleMore,
).forEach {
it.setOnClickListener { expanded = !expanded }
}
}
}
private const val TOGGLE_ANIM_DURATION = 300L
private const val DESC_MIN_LINES = 2
private const val SHRUNK_DESC_MAX_LINES = 3

View File

@@ -4,6 +4,7 @@ import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
@@ -11,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.databinding.DialogStubQuadstatemultichoiceBinding
import eu.kanade.tachiyomi.databinding.DialogStubTextinputBinding
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
fun MaterialAlertDialogBuilder.setTextInput(
hint: String? = null,
@@ -71,3 +74,19 @@ fun MaterialAlertDialogBuilder.setQuadStateMultiChoiceItems(
}
return setView(binding.root)
}
suspend fun MaterialAlertDialogBuilder.await(
@StringRes positiveLabelId: Int,
@StringRes negativeLabelId: Int,
@StringRes neutralLabelId: Int? = null,
) = suspendCancellableCoroutine<Int> { cont ->
setPositiveButton(positiveLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_POSITIVE) }
setNegativeButton(negativeLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEGATIVE) }
if (neutralLabelId != null) {
setNeutralButton(neutralLabelId) { _, _ -> cont.resume(AlertDialog.BUTTON_NEUTRAL) }
}
setOnDismissListener { cont.cancel() }
val dialog = show()
cont.invokeOnCancellation { dialog.dismiss() }
}