Migrate Updates screen to compose (#7534)

* Migrate Updates screen to compose

* Review Changes + Cleanup

Remove more unused stuff and show confirmation dialog when mass deleting chapters

* Review Changes 2 + Rebase
This commit is contained in:
AntsyLich
2022-07-18 08:17:40 +06:00
committed by GitHub
parent bdc5d557d1
commit d8fb6b893f
37 changed files with 1170 additions and 894 deletions

View File

@@ -226,7 +226,7 @@ class MainActivity : BaseActivity() {
if (!router.hasRootController()) {
// Set start screen
if (!handleIntentAction(intent)) {
setSelectedNavItem(startScreenId)
moveToStartScreen()
}
}
syncActivityViewWithController()
@@ -483,10 +483,15 @@ class MainActivity : BaseActivity() {
}
override fun onBackPressed() {
// Updates screen has custom back handler
if (router.getControllerWithTag("${R.id.nav_updates}") != null) {
router.handleBack()
return
}
val backstackSize = router.backstackSize
if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
// Return to start screen
setSelectedNavItem(startScreenId)
moveToStartScreen()
} else if (shouldHandleExitConfirmation()) {
// Exit confirmation (resets after 2 seconds)
lifecycleScope.launchUI { resetExitConfirmation() }
@@ -499,6 +504,10 @@ class MainActivity : BaseActivity() {
}
}
fun moveToStartScreen() {
setSelectedNavItem(startScreenId)
}
override fun onSupportActionModeStarted(mode: ActionMode) {
binding.appbar.apply {
tag = isTransparentWhenNotLifted

View File

@@ -27,7 +27,7 @@ import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.util.calculateWindowWidthSizeClass

View File

@@ -7,8 +7,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.model.Download

View File

@@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter.base
import android.view.View
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadAction
open class BaseChapterHolder(
view: View,

View File

@@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.recent
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.databinding.SectionHeaderItemBinding
import eu.kanade.tachiyomi.util.lang.toRelativeString
import java.text.DateFormat
import java.util.Date
class DateSectionItem(
private val date: Date,
private val range: Int,
private val dateFormat: DateFormat,
) : AbstractHeaderItem<DateSectionItem.DateSectionItemHolder>() {
override fun getLayoutRes(): Int {
return R.layout.section_header_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): DateSectionItemHolder {
return DateSectionItemHolder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: DateSectionItemHolder, position: Int, payloads: List<Any?>?) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is DateSectionItem) {
return date == other.date
}
return false
}
override fun hashCode(): Int {
return date.hashCode()
}
inner class DateSectionItemHolder(private val view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter, true) {
private val binding = SectionHeaderItemBinding.bind(view)
fun bind(item: DateSectionItem) {
binding.title.text = item.date.toRelativeString(view.context, range, dateFormat)
}
}
}

View File

@@ -1,33 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
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 ConfirmDeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : ConfirmDeleteChaptersDialog.Listener {
private var chaptersToDelete = emptyList<UpdatesItem>()
constructor(target: T, chaptersToDelete: List<UpdatesItem>) : this() {
this.chaptersToDelete = chaptersToDelete
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(chaptersToDelete)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteChapters(chaptersToDelete: List<UpdatesItem>)
}
}

View File

@@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.content.Context
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
class UpdatesAdapter(
val controller: UpdatesController,
context: Context,
val items: List<IFlexible<*>>?,
) : BaseChaptersAdapter<IFlexible<*>>(controller, items) {
var readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
var unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
var bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val coverClickListener: OnCoverClickListener = controller
init {
setDisplayHeadersAtStartUp(true)
}
interface OnCoverClickListener {
fun onCoverClick(position: Int)
}
}

View File

@@ -1,149 +1,65 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.presentation.util.NavBarVisibility
import eu.kanade.presentation.util.toBoolean
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
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.databinding.UpdatesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import eu.kanade.tachiyomi.widget.ActionModeWithToolbar
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import eu.kanade.tachiyomi.widget.materialdialogs.await
import kotlinx.coroutines.launch
import logcat.LogPriority
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
/**
* Fragment that shows recent chapters.
*/
class UpdatesController :
NucleusController<UpdatesControllerBinding, UpdatesPresenter>(),
RootController,
ActionModeWithToolbar.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
BaseChaptersAdapter.OnChapterClickListener,
ConfirmDeleteChaptersDialog.Listener,
UpdatesAdapter.OnCoverClickListener {
FullComposeController<UpdatesPresenter>(),
RootController {
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionModeWithToolbar? = null
override fun createPresenter() = UpdatesPresenter()
/**
* Adapter containing the recent chapters.
*/
var adapter: UpdatesAdapter? = null
private set
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_recent_updates)
}
override fun createPresenter(): UpdatesPresenter {
return UpdatesPresenter()
}
override fun createBinding(inflater: LayoutInflater) = UpdatesControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
@Composable
override fun ComposeContent() {
val state by presenter.state.collectAsState()
when (state) {
is UpdatesState.Loading -> LoadingScreen()
is UpdatesState.Error -> Text(text = (state as UpdatesState.Error).error.message.orEmpty())
is UpdatesState.Success ->
UpdateScreen(
state = (state as UpdatesState.Success),
onClickCover = this::openManga,
onClickUpdate = this::openChapter,
onDownloadChapter = this::downloadChapters,
onUpdateLibrary = this::updateLibrary,
onBackClicked = this::onBackClicked,
toggleNavBarVisibility = this::toggleNavBarVisibility,
// For bottom action menu
onMultiBookmarkClicked = { updatesItems, bookmark ->
presenter.bookmarkUpdates(updatesItems, bookmark)
},
onMultiMarkAsReadClicked = { updatesItems, read ->
presenter.markUpdatesRead(updatesItems, read)
},
onMultiDeleteClicked = this::deleteChaptersWithConfirmation,
)
}
view.context.notificationManager.cancel(Notifications.ID_NEW_CHAPTERS)
// Init RecyclerView and adapter
val layoutManager = LinearLayoutManager(view.context)
binding.recycler.layoutManager = layoutManager
binding.recycler.setHasFixedSize(true)
binding.recycler.scrollStateChanges()
.onEach {
// Disable swipe refresh when view is not at the top
val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
binding.swipeRefresh.isEnabled = firstPos <= 0
}
.launchIn(viewScope)
binding.swipeRefresh.isRefreshing = true
binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt())
binding.swipeRefresh.refreshes()
.onEach {
updateLibrary()
// It can be a very long operation, so we disable swipe refresh and show a toast.
binding.swipeRefresh.isRefreshing = false
}
.launchIn(viewScope)
viewScope.launch {
presenter.updates.collectLatest { updatesItems ->
destroyActionModeIfNeeded()
if (adapter == null) {
adapter = UpdatesAdapter(this@UpdatesController, binding.recycler.context, updatesItems)
binding.recycler.adapter = adapter
adapter!!.fastScroller = binding.fastScroller
} else {
adapter?.updateDataSet(updatesItems)
}
binding.swipeRefresh.isRefreshing = false
binding.fastScroller.isVisible = true
binding.recycler.onAnimationsFinished {
(activity as? MainActivity)?.ready = true
}
}
}
}
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
adapter = null
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.updates, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_update_library -> updateLibrary()
}
return super.onOptionsItemSelected(item)
}
private fun updateLibrary() {
@@ -154,262 +70,67 @@ class UpdatesController :
}
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
private fun getSelectedChapters(): List<UpdatesItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? UpdatesItem }
// Let compose view handle this
override fun handleBack(): Boolean {
(activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.onBackPressed()
return true
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
// Get item from position
val item = adapter.getItem(position) as? UpdatesItem ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
true
} else {
openChapter(item)
false
}
private fun onBackClicked() {
(activity as? MainActivity)?.moveToStartScreen()
}
/**
* Called when item in list is long clicked
* @param position position of clicked item
*/
override fun onItemLongClick(position: Int) {
val activity = activity
if (actionMode == null && activity is MainActivity) {
actionMode = activity.startActionModeAndToolbar(this)
activity.showBottomNav(false)
}
toggleSelection(position)
}
/**
* Called to toggle selection
* @param position position of selected item
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
adapter.toggleSelection(position)
actionMode?.invalidate()
}
/**
* Open chapter in reader
* @param chapter selected chapter
*/
private fun openChapter(item: UpdatesItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.manga.id, item.chapter.id)
startActivity(intent)
private fun toggleNavBarVisibility(navBarVisibility: NavBarVisibility) {
val showNavBar = navBarVisibility.toBoolean()
(activity as? MainActivity)?.showBottomNav(showNavBar)
}
/**
* Download selected items
* @param chapters list of selected [UpdatesItem]s
* @param items list of selected [UpdatesItem]s
*/
private fun downloadChapters(chapters: List<UpdatesItem>) {
presenter.downloadChapters(chapters)
destroyActionModeIfNeeded()
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
binding.emptyView.hide()
} else {
binding.emptyView.show(R.string.information_no_recent)
}
}
/**
* Update download status of chapter
* @param download [Download] object containing download progress.
*/
fun onChapterDownloadUpdate(download: Download) {
adapter?.currentItems
?.filterIsInstance<UpdatesItem>()
?.find { it.chapter.id == download.chapter.id }?.let {
adapter?.updateItem(it, it.status)
private fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) {
if (items.isEmpty()) return
viewScope.launch {
when (action) {
ChapterDownloadAction.START -> {
presenter.downloadChapters(items)
if (items.any { it.downloadStateProvider() == Download.State.ERROR }) {
DownloadService.start(activity!!)
}
}
ChapterDownloadAction.START_NOW -> {
val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch
presenter.startDownloadingNow(chapterId)
}
ChapterDownloadAction.CANCEL -> {
val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch
presenter.cancelDownload(chapterId)
}
ChapterDownloadAction.DELETE -> {
presenter.deleteChapters(items)
}
}
}
/**
* Mark chapter as read
* @param chapters list of chapters
*/
private fun markAsRead(chapters: List<UpdatesItem>) {
presenter.markChapterRead(chapters, true)
destroyActionModeIfNeeded()
}
/**
* Mark chapter as unread
* @param chapters list of selected [UpdatesItem]
*/
private fun markAsUnread(chapters: List<UpdatesItem>) {
presenter.markChapterRead(chapters, false)
destroyActionModeIfNeeded()
}
override fun deleteChapters(chaptersToDelete: List<UpdatesItem>) {
presenter.deleteChapters(chaptersToDelete)
destroyActionModeIfNeeded()
}
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCoverClick(position: Int) {
destroyActionModeIfNeeded()
val chapterClicked = adapter?.getItem(position) as? UpdatesItem ?: return
openManga(chapterClicked)
}
private fun openManga(chapter: UpdatesItem) {
router.pushController(MangaController(chapter.manga.id!!))
}
/**
* Called when chapters are deleted
*/
fun onChaptersDeleted() {
adapter?.notifyDataSetChanged()
}
/**
* Called when error while deleting
* @param error error message
*/
fun onChaptersDeletedError(error: Throwable) {
logcat(LogPriority.ERROR, error)
}
override fun downloadChapter(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
if (item.status == Download.State.ERROR) {
DownloadService.start(activity!!)
} else {
downloadChapters(listOf(item))
}
adapter?.updateItem(item)
}
override fun deleteChapter(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
deleteChapters(listOf(item))
adapter?.updateItem(item)
}
override fun startDownloadNow(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
presenter.startDownloadingNow(item.chapter)
}
private fun bookmarkChapters(chapters: List<UpdatesItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
/**
* Called when ActionMode created.
* @param mode the ActionMode object
* @param menu menu object of ActionMode
*/
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 onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) {
menuInflater.inflate(R.menu.updates_chapter_selection, menu)
}
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()
private fun deleteChaptersWithConfirmation(items: List<UpdatesItem>) {
if (items.isEmpty()) return
viewScope.launch {
val result = MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.await(android.R.string.ok, android.R.string.cancel)
if (result == AlertDialog.BUTTON_POSITIVE) presenter.deleteChapters(items)
}
return true
}
override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
val chapters = getSelectedChapters()
if (chapters.isEmpty()) return
toolbar.findToolbarItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded }
toolbar.findToolbarItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded }
toolbar.findToolbarItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark }
toolbar.findToolbarItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark }
toolbar.findToolbarItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
toolbar.findToolbarItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
private fun openChapter(item: UpdatesItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.update.mangaId, item.update.chapterId)
startActivity(intent)
}
/**
* Called when ActionMode item clicked
* @param mode the ActionMode object
* @param item item from ActionMode.
*/
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 ->
ConfirmDeleteChaptersDialog(this, getSelectedChapters())
.showDialog(router)
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())
else -> return false
}
return true
}
/**
* Called when ActionMode destroyed
* @param mode the ActionMode object
*/
override fun onDestroyActionMode(mode: ActionMode) {
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
}
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = adapter ?: return
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
actionMode?.invalidate()
adapter.notifyDataSetChanged()
private fun openManga(item: UpdatesItem) {
router.pushController(MangaController(item.update.mangaId))
}
}

View File

@@ -1,62 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.kanade.tachiyomi.databinding.UpdatesItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
/**
* Holder that contains chapter item
* UI related actions should be called from here.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new recent chapter holder.
*/
class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) :
BaseChapterHolder(view, adapter) {
private val binding = UpdatesItemBinding.bind(view)
init {
binding.mangaCover.setOnClickListener {
adapter.coverClickListener.onCoverClick(bindingAdapterPosition)
}
binding.download.listener = downloadActionListener
}
fun bind(item: UpdatesItem) {
// Set chapter title
binding.chapterTitle.text = item.chapter.name
// Set manga title
binding.mangaTitle.text = item.manga.title
// Check if chapter is read and/or bookmarked and set correct color
if (item.chapter.read) {
binding.chapterTitle.setTextColor(adapter.readColor)
binding.mangaTitle.setTextColor(adapter.readColor)
} else {
binding.mangaTitle.setTextColor(adapter.unreadColor)
binding.chapterTitle.setTextColor(
if (item.chapter.bookmark) adapter.bookmarkedColor else adapter.unreadColorSecondary,
)
}
// Set bookmark status
binding.bookmarkIcon.isVisible = item.chapter.bookmark
// Set chapter status
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)
// Set cover
binding.mangaCover.dispose()
binding.mangaCover.load(item.manga)
}
}

View File

@@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
class UpdatesItem(chapter: Chapter, val manga: Manga, header: DateSectionItem) :
BaseChapterItem<UpdatesHolder, DateSectionItem>(chapter, header) {
override fun getLayoutRes(): Int {
return R.layout.updates_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): UpdatesHolder {
return UpdatesHolder(view, adapter as UpdatesAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: UpdatesHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
}

View File

@@ -1,233 +1,321 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.os.Bundle
import eu.kanade.data.DatabaseHandler
import eu.kanade.data.manga.mangaChapterMapper
import androidx.compose.runtime.Immutable
import eu.kanade.core.util.insertSeparators
import eu.kanade.domain.chapter.interactor.GetChapter
import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.UpdateChapter
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.chapter.model.ChapterUpdate
import eu.kanade.domain.chapter.model.toDbChapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.updates.interactor.GetUpdates
import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.presentation.updates.UpdatesUiModel
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.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Calendar
import java.util.Date
import java.util.TreeMap
class UpdatesPresenter(
private val preferences: PreferencesHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val handler: DatabaseHandler = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val getUpdates: GetUpdates = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
) : BasePresenter<UpdatesController>() {
private val relativeTime: Int = preferences.relativeTime().get()
private val dateFormat: DateFormat = preferences.dateFormat()
private val _state: MutableStateFlow<UpdatesState> = MutableStateFlow(UpdatesState.Loading)
val state: StateFlow<UpdatesState> = _state.asStateFlow()
private val _updates: MutableStateFlow<List<UpdatesItem>> = MutableStateFlow(listOf())
val updates: StateFlow<List<UpdatesItem>> = _updates.asStateFlow()
/**
* Helper function to update the UI state only if it's currently in success state
*/
private fun updateSuccessState(func: (UpdatesState.Success) -> UpdatesState.Success) {
_state.update { if (it is UpdatesState.Success) func(it) else it }
}
private var incognitoMode = false
set(value) {
updateSuccessState { it.copy(isIncognitoMode = value) }
field = value
}
private var downloadOnlyMode = false
set(value) {
updateSuccessState { it.copy(isDownloadedOnlyMode = value) }
field = value
}
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsStatusJob: Job? = null
private var observeDownloadsPageJob: Job? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
subscribeToUpdates()
// Set date limit for recent chapters
val calendar = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
getUpdates.subscribe(calendar)
.catch { exception ->
_state.value = UpdatesState.Error(exception)
}
.collectLatest { updates ->
val uiModels = updates.toUpdateUiModels()
_state.update { currentState ->
when (currentState) {
is UpdatesState.Success -> currentState.copy(uiModels)
is UpdatesState.Loading, is UpdatesState.Error ->
UpdatesState.Success(
uiModels = uiModels,
isIncognitoMode = incognitoMode,
isDownloadedOnlyMode = downloadOnlyMode,
)
}
}
observeDownloads()
}
}
preferences.incognitoMode()
.asHotFlow { incognito ->
incognitoMode = incognito
}
.launchIn(presenterScope)
preferences.downloadedOnly()
.asHotFlow { downloadedOnly ->
downloadOnlyMode = downloadedOnly
}
.launchIn(presenterScope)
}
private fun List<UpdatesWithRelations>.toUpdateUiModels(): List<UpdatesUiModel> {
return this.map { update ->
val activeDownload = downloadManager.queue.find { update.chapterId == it.chapter.id }
val downloaded = downloadManager.isChapterDownloaded(
update.chapterName,
update.scanlator,
update.mangaTitle,
update.sourceId,
)
val downloadState = when {
activeDownload != null -> activeDownload.status
downloaded -> Download.State.DOWNLOADED
else -> Download.State.NOT_DOWNLOADED
}
val item = UpdatesItem(
update = update,
downloadStateProvider = { downloadState },
downloadProgressProvider = { activeDownload?.progress ?: 0 },
)
UpdatesUiModel.Item(item)
}
.insertSeparators { before, after ->
val beforeDate = before?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
when {
beforeDate.time != afterDate.time && afterDate.time != 0L ->
UpdatesUiModel.Header(afterDate)
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
private suspend fun observeDownloads() {
observeDownloadsStatusJob?.cancel()
observeDownloadsStatusJob = presenterScope.launchIO {
downloadManager.queue.getStatusAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
onDownloadStatusChange(it)
view?.onChapterDownloadUpdate(it)
updateDownloadState(it)
}
}
}
observeDownloadsPageJob?.cancel()
observeDownloadsPageJob = presenterScope.launchIO {
downloadManager.queue.getProgressAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
view?.onChapterDownloadUpdate(it)
updateDownloadState(it)
}
}
}
}
/**
* Get observable containing recent chapters and date
*/
private suspend fun subscribeToUpdates() {
// Set date limit for recent chapters
val cal = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
handler
.subscribeToList {
mangasQueries.getRecentlyUpdated(after = cal.timeInMillis, mangaChapterMapper)
}
.map { mangaChapter ->
val map = TreeMap<Date, MutableList<Pair<Manga, Chapter>>> { d1, d2 -> d2.compareTo(d1) }
val byDate = mangaChapter.groupByTo(map) { it.second.dateFetch.toDateKey() }
byDate.flatMap { entry ->
val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat)
entry.value
.sortedWith(compareBy({ it.second.dateFetch }, { it.second.chapterNumber })).asReversed()
.map { UpdatesItem(it.second, it.first, dateItem) }
}
}
.collectLatest { list ->
list.forEach { item ->
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == item.chapter.id }
// If there's an active download, assign it, otherwise ask the manager if
// the chapter is downloaded and assign it to the status.
if (download != null) {
item.download = download
}
}
setDownloadedChapters(list)
_updates.value = list
// Set unread chapter count for bottom bar badge
preferences.unreadUpdatesCount().set(list.count { !it.chapter.read })
}
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param items the list of chapter from the database.
*/
private fun setDownloadedChapters(items: List<UpdatesItem>) {
for (item in items) {
val manga = item.manga
val chapter = item.chapter
if (downloadManager.isChapterDownloaded(chapter.name, chapter.scanlator, manga.title, manga.source)) {
item.status = Download.State.DOWNLOADED
}
}
}
/**
* Update status of chapters.
*
* @param download download object containing progress.
*/
private fun onDownloadStatusChange(download: Download) {
// Assign the download to the model object.
if (download.status == Download.State.QUEUE) {
val chapters = (view?.adapter?.currentItems ?: emptyList()).filterIsInstance<UpdatesItem>()
val chapter = chapters.find { it.chapter.id == download.chapter.id }
if (chapter != null && chapter.download == null) {
chapter.download = download
private fun updateDownloadState(download: Download) {
updateSuccessState { successState ->
val modifiedIndex = successState.uiModels.indexOfFirst {
it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
}
if (modifiedIndex < 0) return@updateSuccessState successState
val newUiModels = successState.uiModels.toMutableList().apply {
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { download.status },
downloadProgressProvider = { download.progress },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
}
successState.copy(uiModels = newUiModels)
}
}
fun startDownloadingNow(chapter: Chapter) {
downloadManager.startDownloadNow(chapter.id)
fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapterId)
}
fun cancelDownload(chapterId: Long) {
val activeDownload = downloadManager.queue.find { chapterId == it.chapter.id } ?: return
downloadManager.deletePendingDownload(activeDownload)
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
}
/**
* Mark selected chapter as read
*
* @param items list of selected chapters
* @param read read status
* Mark the selected updates list as read/unread.
* @param updates the list of selected updates.
* @param read whether to mark chapters as read or unread.
*/
fun markChapterRead(items: List<UpdatesItem>, read: Boolean) {
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
presenterScope.launchIO {
setReadStatus.await(
read = read,
values = items
.map { it.chapter }
values = updates
.mapNotNull { getChapter.await(it.update.chapterId) }
.toTypedArray(),
)
}
}
/**
* Delete selected chapters
*
* @param chapters list of chapters
* Bookmarks the given list of chapters.
* @param updates the list of chapters to bookmark.
*/
fun deleteChapters(chapters: List<UpdatesItem>) {
launchIO {
try {
deleteChaptersInternal(chapters)
withUIContext { view?.onChaptersDeleted() }
} catch (e: Throwable) {
withUIContext { view?.onChaptersDeletedError(e) }
}
}
}
/**
* Mark selected chapters as bookmarked
* @param items list of selected chapters
* @param bookmarked bookmark status
*/
fun bookmarkChapters(items: List<UpdatesItem>, bookmarked: Boolean) {
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
presenterScope.launchIO {
val toUpdate = items.map {
ChapterUpdate(
bookmark = bookmarked,
id = it.chapter.id,
)
}
updateChapter.awaitAll(toUpdate)
updates
.filterNot { it.update.bookmark == bookmark }
.map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
.let { updateChapter.awaitAll(it) }
}
}
/**
* Download selected chapters
* @param items list of recent chapters seleted.
* Downloads the given list of chapters with the manager.
* @param updatesItem the list of chapters to download.
*/
fun downloadChapters(items: List<UpdatesItem>) {
items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter.toDbChapter())) }
fun downloadChapters(updatesItem: List<UpdatesItem>) {
launchIO {
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
for (updates in groupedUpdates) {
val mangaId = updates.first().update.mangaId
val manga = getManga.await(mangaId) ?: continue
// Don't download if source isn't available
sourceManager.get(manga.source) ?: continue
val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() }
downloadManager.downloadChapters(manga, chapters)
}
}
}
/**
* Delete selected chapters
*
* @param items chapters selected
* @param updatesItem list of chapters
*/
private fun deleteChaptersInternal(chapterItems: List<UpdatesItem>) {
val itemsByManga = chapterItems.groupBy { it.manga.id }
for ((_, items) in itemsByManga) {
val manga = items.first().manga
val source = sourceManager.get(manga.source) ?: continue
val chapters = items.map { it.chapter.toDbChapter() }
fun deleteChapters(updatesItem: List<UpdatesItem>) {
launchIO {
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
val deletedIds = groupedUpdates.flatMap { updates ->
val mangaId = updates.first().update.mangaId
val manga = getManga.await(mangaId) ?: return@flatMap emptyList()
val source = sourceManager.get(manga.source) ?: return@flatMap emptyList()
val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() }
downloadManager.deleteChapters(chapters, manga, source).mapNotNull { it.id }
}
updateSuccessState { successState ->
val deletedUpdates = successState.uiModels.filter {
it is UpdatesUiModel.Item && deletedIds.contains(it.item.update.chapterId)
}
if (deletedUpdates.isEmpty()) return@updateSuccessState successState
downloadManager.deleteChapters(chapters, manga, source)
items.forEach {
it.status = Download.State.NOT_DOWNLOADED
it.download = null
// TODO: Don't do this fake status update
val newUiModels = successState.uiModels.toMutableList().apply {
deletedUpdates.forEach { deletedUpdate ->
val modifiedIndex = indexOf(deletedUpdate)
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { Download.State.NOT_DOWNLOADED },
downloadProgressProvider = { 0 },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
}
}
successState.copy(uiModels = newUiModels)
}
}
}
}
sealed class UpdatesState {
object Loading : UpdatesState()
data class Error(val error: Throwable) : UpdatesState()
data class Success(
val uiModels: List<UpdatesUiModel>,
val isIncognitoMode: Boolean = false,
val isDownloadedOnlyMode: Boolean = false,
val showSwipeRefreshIndicator: Boolean = false,
) : UpdatesState()
}
@Immutable
data class UpdatesItem(
val update: UpdatesWithRelations,
val downloadStateProvider: () -> Download.State,
val downloadProgressProvider: () -> Int,
)