Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.details.ui.model.MangaBranch
class BranchComparator : Comparator<MangaBranch> {
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
}

View File

@@ -0,0 +1,122 @@
package org.koitharu.kotatsu.details.service
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@AndroidEntryPoint
class MangaPrefetchService : CoroutineIntentService() {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var cache: ContentCache
@Inject
lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
}
}
override fun onError(startId: Int, error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source)
runCatchingCancellable { source.getDetails(manga) }
}
private suspend fun prefetchPages(chapter: MangaChapter) {
val source = mangaRepositoryFactory.create(chapter.source)
runCatchingCancellable { source.getPages(chapter) }
}
private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return
val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters
if (chapters.isNullOrEmpty()) {
return
}
val history = historyRepository.getOne(last)
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTER = "manga"
private const val ACTION_PREFETCH_DETAILS = "details"
private const val ACTION_PREFETCH_PAGES = "pages"
private const val ACTION_PREFETCH_LAST = "last"
fun prefetchDetails(context: Context, manga: Manga) {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
context.startService(intent)
}
fun prefetchPages(context: Context, chapter: MangaChapter) {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
}
fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST
context.startService(intent)
}
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled()
}
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.details.service
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PrefetchCompanionEntryPoint {
val settings: AppSettings
val contentCache: ContentCache
}

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.details.ui
import android.view.View
import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
class ChaptersBottomSheetMediator(
bottomSheet: View,
) : OnBackPressedCallback(false),
ActionModeListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
OnLayoutChangeListener {
private val behavior = BottomSheetBehavior.from(bottomSheet)
private var lockCounter = 0
override fun handleOnBackPressed() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
override fun onActionModeStarted(mode: ActionMode) {
lock()
}
override fun onActionModeFinished(mode: ActionMode) {
unlock()
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val height = bottom - top
if (height != behavior.peekHeight) {
behavior.peekHeight = height
}
}
fun lock() {
lockCounter++
behavior.isDraggable = lockCounter <= 0
}
fun unlock() {
lockCounter--
behavior.isDraggable = lockCounter <= 0
}
}

View File

@@ -0,0 +1,210 @@
package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem>,
ListSelectionController.Callback2 {
private val viewModel by activityViewModels<DetailsViewModel>()
private var chaptersAdapter: ChaptersAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentChaptersBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentChaptersBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
chaptersAdapter = ChaptersAdapter(this)
selectionController = ListSelectionController(
activity = requireActivity(),
decoration = ChaptersSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
with(binding.recyclerViewChapters) {
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
adapter = chaptersAdapter
}
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
}
override fun onDestroyView() {
chaptersAdapter = null
selectionController = null
super.onDestroyView()
}
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionController?.onItemClick(item.chapter.id) == true) {
return
}
startActivity(
ReaderActivity.newIntent(
context = view.context,
manga = viewModel.manga.value ?: return,
state = ReaderState(item.chapter.id, 0, 0),
),
scaleUpActivityOptionsOf(view).toBundle(),
)
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
return selectionController?.onItemLongClick(item.chapter.id) ?: false
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.download(selectionController?.snapshot())
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids)
Snackbar.make(
requireViewBinding().recyclerViewChapters,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG,
).show()
}
}
mode.finish()
true
}
R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds())
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
if (x.chapter.id in ids) {
isAdding = true
if (buffer.isNotEmpty()) {
ids.addAll(buffer)
buffer.clear()
}
} else if (isAdding) {
buffer.add(x.chapter.id)
}
}
controller.addAll(ids)
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
controller.addAll(ids)
true
}
R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false
viewModel.markChapterAsCurrent(id)
mode.finish()
true
}
else -> false
}
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val selectedIds = selectionController?.peekCheckedIds() ?: return false
val allItems = chaptersAdapter?.items.orEmpty()
val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds }
menu.findItem(R.id.action_save).isVisible = items.none { (_, x) ->
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) ->
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {
if (items[i].index + 1 != items[i + 1].index) {
hasGap = true
break
}
}
menu.findItem(R.id.action_select_range).isVisible = hasGap
return true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerViewChapters.invalidateItemDecorations()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
private fun onChaptersChanged(list: List<ChapterListItem>) {
val adapter = chaptersAdapter ?: return
if (adapter.itemCount == 0) {
val position = list.indexOfFirst { it.isCurrent } - 1
if (position > 0) {
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(
list,
RecyclerViewScrollCallback(requireViewBinding().recyclerViewChapters, position, offset),
)
} else {
adapter.items = list
}
} else {
adapter.items = list
}
}
private fun onLoadingStateChanged(isLoading: Boolean) {
requireViewBinding().progressBar.isVisible = isLoading
}
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga
fun mapChapters(
remoteManga: Manga?,
localManga: Manga?,
history: MangaHistory?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList()
}
val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size)
val ids = buildSet(chaptersSize) {
remoteChapters.mapTo(this) { it.id }
localChapters.mapTo(this) { it.id }
}
val result = ArrayList<ChapterListItem>(chaptersSize)
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
var isUnread = currentId !in ids
for (chapter in remoteChapters) {
val local = localMap?.remove(chapter.id)
if (chapter.id == currentId) {
isUnread = true
}
result += chapter.toListItem(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null,
)
}
if (!localMap.isNullOrEmpty()) {
for (chapter in localMap.values) {
if (chapter.id == currentId) {
isUnread = true
}
result += chapter.toListItem(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = false,
isDownloaded = false,
)
}
}
return result
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.details.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class ChaptersMenuProvider(
private val viewModel: DetailsViewModel,
private val bottomSheetMediator: ChaptersBottomSheetMediator?,
) : MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_chapters, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_reversed -> {
viewModel.setChaptersReversed(!menuItem.isChecked)
true
}
else -> false
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock()
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performChapterSearch(newText)
return true
}
}

View File

@@ -0,0 +1,332 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.adapter.branchAD
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import javax.inject.Inject
@AndroidEntryPoint
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
NoModalBottomSheetOwner,
View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener {
override val bsHeader: BottomSheetHeaderBar?
get() = viewBinding.headerChapters
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
private lateinit var viewBadge: ViewBadge
private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this)
chaptersMenuProvider = if (viewBinding.layoutBottom != null) {
val bsMediator = ChaptersBottomSheetMediator(checkNotNull(viewBinding.layoutBottom))
actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnExpansionChangeListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator)
ChaptersMenuProvider(viewModel, bsMediator)
} else {
ChaptersMenuProvider(viewModel, null)
}
viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(
this,
SnackbarErrorObserver(
host = viewBinding.containerDetails,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
}
},
),
)
viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranchName.observe(this) {
viewBinding.headerChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
}
viewModel.isChaptersReversed.observe(this) {
viewBinding.headerChapters?.invalidateMenu() ?: invalidateOptionsMenu()
}
viewModel.favouriteCategories.observe(this) {
invalidateOptionsMenu()
}
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(viewBinding.containerDetails))
addMenuProvider(
DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.containerChapters,
shortcutsUpdater = shortcutsUpdater,
),
)
viewBinding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.button_dropdown -> showBranchPopupMenu()
}
}
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
R.id.action_pages_thumbs -> {
val history = viewModel.historyInfo.value?.history
PagesThumbnailsSheet.show(
fm = supportFragmentManager,
manga = viewModel.manga.value ?: return false,
chapterId = history?.chapterId
?: viewModel.chapters.value?.firstOrNull()?.chapter?.id
?: return false,
currentPage = history?.page ?: 0,
)
true
}
else -> false
}
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) {
headerBar.addMenuProvider(chaptersMenuProvider)
} else {
headerBar.removeMenuProvider(chaptersMenuProvider)
}
viewBinding.buttonRead.isGone = isExpanded
}
private fun onMangaUpdated(manga: Manga) {
title = manga.title
val hasChapters = !manga.chapters.isNullOrEmpty()
viewBinding.buttonRead.isEnabled = hasChapters
invalidateOptionsMenu()
showBottomSheet(manga.chapters != null)
viewBinding.groupHeader?.isVisible = hasChapters
}
private fun onMangaRemoved(manga: Manga) {
Toast.makeText(
this,
getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT,
).show()
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f)
}
}
private fun onHistoryChanged(info: HistoryInfo) {
with(viewBinding.buttonRead) {
if (info.history != null) {
setText(R.string._continue)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
} else {
setText(R.string.read)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
}
}
val text = when {
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
}
viewBinding.headerChapters?.title = text
viewBinding.textViewTitle?.text = text
}
private fun onNewChaptersChanged(newChapters: Int) {
viewBadge.counter = newChapters
}
private fun showBranchPopupMenu() {
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<MangaBranch> { item, _ ->
viewModel.setSelectedBranch(item.name)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<MangaBranch>(this)
.addAdapterDelegate(branchAD(listener))
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.translations)
.setItems(viewModel.branches.value.orEmpty())
.create()
.also { it.show() }
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value?.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show()
} else {
startActivity(
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
isIncognitoMode = isIncognitoMode,
),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
}
private fun isTabletLayout() = viewBinding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return
val transition = Slide(Gravity.BOTTOM)
transition.addTarget(view)
transition.interpolator = AccelerateDecelerateInterpolator()
TransitionManager.beginDelayedTransition(viewBinding.root as ViewGroup, transition)
view.isVisible = isVisible
}
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.headerChapters
}
return sb
}
private class PrefetchObserver(
private val context: Context,
) : Observer<List<ChapterListItem>?> {
private var isCalled = false
override fun onChanged(value: List<ChapterListItem>?) {
if (value.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = value.find { it.isCurrent } ?: value.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
}
companion object {
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
}
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}

View File

@@ -0,0 +1,319 @@
package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
@AndroidEntryPoint
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: MangaTagHighlighter
private val viewModel by activityViewModels<DetailsViewModel>()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentDetailsBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentDetailsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.newIntent(view.context, item),
scaleUpActivityOptionsOf(view).toBundle(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_bookmark)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> viewModel.removeBookmark(item)
}
true
}
menu.show()
return true
}
private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) {
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
} else {
ratingBar.isVisible = false
}
when (manga.state) {
MangaState.FINISHED -> {
infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
}
MangaState.ONGOING -> {
infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
}
else -> infoLayout.textViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false
} else {
infoLayout.textViewSource.text = manga.source.title
infoLayout.textViewSource.isVisible = true
}
infoLayout.textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
}
}
private fun onDescriptionChanged(description: CharSequence?) {
if (description.isNullOrBlank()) {
requireViewBinding().textViewDescription.setText(R.string.no_description)
} else {
requireViewBinding().textViewDescription.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val textView = requireViewBinding().infoLayout.textViewSize
if (size == 0L) {
textView.isVisible = false
} else {
textView.text = FileSize.BYTES.format(textView.context, size)
textView.isVisible = true
}
}
private fun onHistoryChanged(history: HistoryInfo) {
requireViewBinding().progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading) {
requireViewBinding().progressBar.show()
} else {
requireViewBinding().progressBar.hide()
}
}
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {
var adapter = requireViewBinding().recyclerViewBookmarks.adapter as? BookmarksAdapter
requireViewBinding().groupBookmarks.isGone = bookmarks.isEmpty()
if (adapter != null) {
adapter.items = bookmarks
} else {
adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
adapter.items = bookmarks
requireViewBinding().recyclerViewBookmarks.adapter = adapter
val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
requireViewBinding().recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = requireViewBinding().recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
requireViewBinding().recyclerViewScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(viewLifecycleOwner, coil, childFragmentManager)
adapter.items = scrobblings
requireViewBinding().recyclerViewScrobbling.adapter = adapter
requireViewBinding().recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.textView_author -> {
startActivity(
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
),
)
}
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v).toBundle(),
)
}
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().root.updatePadding(
bottom = (
(activity as? NoModalBottomSheetOwner)?.bsHeader?.measureHeight()
?.plus(insets.bottom)?.plus(resources.resolveDp(16))
)
?: insets.bottom,
)
}
private fun bindTags(manga: Manga) {
requireViewBinding().chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTint(tag),
data = tag,
isCheckable = false,
isChecked = false,
)
},
)
}
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
if (lastResult?.request?.data == imageUrl) {
return
}
val request = ImageRequest.Builder(context ?: return)
.target(requireViewBinding().imageViewCover)
.size(CoverSizeResolver(requireViewBinding().imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(requireContext())
.lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
}
request.enqueueWith(coil)
}
}

View File

@@ -0,0 +1,159 @@
package org.koitharu.kotatsu.details.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
class DetailsMenuProvider(
private val activity: FragmentActivity,
private val viewModel: DetailsViewModel,
private val snackbarHost: View,
private val shortcutsUpdater: ShortcutsUpdater,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
}
override fun onPrepareMenu(menu: Menu) {
val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_share -> {
viewModel.manga.value?.let {
val shareHelper = ShareHelper(activity)
if (it.source == MangaSource.LOCAL) {
shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
} else {
shareHelper.shareMangaLink(it)
}
}
}
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.delete_manga)
.setMessage(activity.getString(R.string.text_delete_local_manga, title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
viewModel.download(null)
}
}
}
R.id.action_browser -> {
viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title))
}
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
}
}
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null)
}
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
activity.lifecycleScope.launch {
if (!shortcutsUpdater.requestPinShortcut(it)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
}
}
}
}
else -> return false
}
return true
}
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
val dialogBuilder = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
val currentBranch = branches.indexOfFirst { it.isSelected }
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
viewModel.download(chaptersIds)
}
} else {
dialogBuilder.setMessage(
activity.getString(
R.string.large_manga_save_confirm,
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
viewModel.download(null)
}
}
dialogBuilder.show()
}
}

View File

@@ -0,0 +1,370 @@
package org.koitharu.kotatsu.details.ui
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() {
private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>()
private val mangaData = combine(
delegate.onlineManga,
delegate.localManga,
) { o, l ->
o ?: l
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
private val history = historyRepository.observeOne(delegate.mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val manga = mangaData.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val historyInfo: LiveData<HistoryInfo> = combine(
mangaData,
delegate.selectedBranch,
history,
historyRepository.observeShouldSkip(mangaData),
) { m, b, h, im ->
HistoryInfo(m, b, h, im)
}.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, null, false),
)
val bookmarks = mangaData.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = delegate.localManga
.map {
if (it != null) {
val file = it.url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0)
val description = mangaData
.distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest {
val description = it?.description
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable }
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = combine(
scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<MangaBranch>> = combine(
delegate.onlineManga,
delegate.localManga,
delegate.selectedBranch,
) { m, l, b ->
val chapters = concat(m?.chapters, l?.chapters)
if (chapters.isEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
val isChaptersEmpty: LiveData<Boolean> = combine(
delegate.onlineManga,
delegate.localManga,
isLoading.asFlow(),
) { manga, local, loading ->
(manga != null && manga.chapters.isNullOrEmpty()) &&
(local != null && local.chapters.isNullOrEmpty()) &&
!loading
}.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
delegate.onlineManga,
delegate.localManga,
history,
delegate.selectedBranch,
newChapters,
) { manga, local, history, branch, news ->
mapChapters(manga, local, history, news, branch)
},
chaptersReversed,
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String?
get() = delegate.selectedBranch.value
init {
loadingJob = doLoad()
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
}
fun reload() {
loadingJob.cancel()
loadingJob = doLoad()
}
fun deleteLocal() {
val m = delegate.localManga.value
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
}
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.emitCall(manga)
}
}
fun removeBookmark(bookmark: Bookmark) {
launchJob {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
onShowToast.call(R.string.bookmark_removed)
}
}
fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue
}
fun setSelectedBranch(branch: String?) {
delegate.selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
return delegate.onlineManga.value
}
fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty()
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling(index: Int) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
}
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(mangaData.value)
val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
}
}
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
delegate.onlineManga.value ?: checkNotNull(manga.value),
chaptersIds,
)
onDownloadStarted.emitCall(Unit)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
if (query.isEmpty() || this.isEmpty()) {
return this
}
return filter {
it.chapter.name.contains(query, ignoreCase = true)
}
}
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = mangaData.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.publishManga(it)
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index)
val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else {
null
}
if (scrobbler == null) {
errorEvent.call(IllegalStateException("Scrobbler [$index] is not available"))
}
return scrobbler
}
private fun <T> concat(a: List<T>?, b: List<T>?): List<T> {
return when {
a == null && b == null -> emptyList<T>()
a == null && b != null -> b
a != null && b == null -> a
a != null && b != null -> buildList<T>(a.size + b.size) {
addAll(a)
addAll(b)
}
else -> error("This shouldn't have happened")
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> DetailsFragment()
1 -> ChaptersFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position")
}
}

View File

@@ -0,0 +1,89 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@ViewModelScoped
class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
lifecycle: ViewModelLifecycle,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
networkState: NetworkState,
) {
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
private val intent = MangaIntent(savedStateHandle)
private val onlineMangaStateFlow = MutableStateFlow<Manga?>(null)
private val localMangaStateFlow = MutableStateFlow<Manga?>(null)
val onlineManga = combine(
onlineMangaStateFlow,
networkState,
) { m, s -> m.takeIf { s } }
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val localManga = localMangaStateFlow.asStateFlow()
val selectedBranch = MutableStateFlow<String?>(null)
val mangaId = intent.manga?.id ?: intent.mangaId
init {
intent.manga?.let {
publishManga(it)
}
}
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
publishManga(manga)
manga = mangaRepositoryFactory.create(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
publishManga(manga)
runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)?.manga
}
}.onFailure { error ->
error.printStackTraceDebug()
}.onSuccess {
if (it != null) {
publishManga(it)
}
}
}
fun publishManga(manga: Manga) {
if (manga.source == MangaSource.LOCAL) {
localMangaStateFlow
} else {
onlineMangaStateFlow
}.value = manga
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.text.Spannable
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.details.ui.model.MangaBranch
fun branchAD(
clickListener: OnListItemClickListener<MangaBranch>,
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickAdapter)
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
val counterSizeSpan = RelativeSizeSpan(0.86f)
bind {
binding.root.text = buildSpannedString {
append(item.name ?: getString(R.string.system_default))
append(' ')
append(' ')
val start = length
append(item.count.toString())
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.root.isChecked = item.isSelected
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.details.ui.model.MangaBranch
class BranchesAdapter(
list: List<MangaBranch>,
listener: OnListItemClickListener<MangaBranch>,
) : ListDelegationAdapter<List<MangaBranch>>() {
init {
delegatesManager.addDelegate(branchAD(listener))
items = list
}
}

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.details.ui.adapter
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import com.google.android.material.R as materialR
fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString()
binding.textViewDescription.textAndVisible = item.description()
}
when {
item.isCurrent -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_primary)
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnPrimary))
}
item.isUnread -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnTertiary))
}
else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
}
}
binding.imageViewDownloaded.isVisible = item.isDownloaded
binding.imageViewNew.isVisible = item.isNew
}
}

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()), FastScroller.SectionIndexer {
init {
setHasStableIds(true)
delegatesManager.addDelegate(chapterListItemAD(onItemClickListener))
}
override fun getItemId(position: Int): Long {
return items[position].chapter.id
}
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
return oldItem.chapter.id == newItem.chapter.id
}
override fun areContentsTheSame(
oldItem: ChapterListItem,
newItem: ChapterListItem
): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
if (oldItem.flags != newItem.flags && oldItem.chapter == newItem.chapter) {
return newItem.flags
}
return null
}
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val item = items.getOrNull(position) ?: return null
return item.chapter.number.toString()
}
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
init {
paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY),
98,
)
paint.style = Paint.Style.FILL
}
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
canvas.drawRoundRect(bounds, radius, radius, paint)
}
}

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.details.ui.model
import android.text.format.DateUtils
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
private val uploadDateMs: Long,
) {
var uploadDate: CharSequence? = null
private set
get() {
if (field != null) return field
if (uploadDateMs == 0L) return null
field = DateUtils.getRelativeTimeSpanString(
uploadDateMs,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
)
return field
}
val isCurrent: Boolean
get() = hasFlag(FLAG_CURRENT)
val isUnread: Boolean
get() = hasFlag(FLAG_UNREAD)
val isDownloaded: Boolean
get() = hasFlag(FLAG_DOWNLOADED)
val isNew: Boolean
get() = hasFlag(FLAG_NEW)
fun description(): CharSequence? {
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
return when {
uploadDate != null && scanlator != null -> "$uploadDate$scanlator"
scanlator != null -> scanlator
else -> uploadDate
}
}
private fun hasFlag(flag: Int): Boolean {
return (flags and flag) == flag
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChapterListItem
if (chapter != other.chapter) return false
if (flags != other.flags) return false
return uploadDateMs == other.uploadDateMs
}
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDateMs.hashCode()
return result
}
companion object {
const val FLAG_UNREAD = 2
const val FLAG_CURRENT = 4
const val FLAG_NEW = 8
const val FLAG_DOWNLOADED = 32
}
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
class HistoryInfo(
val totalChapters: Int,
val currentChapter: Int,
val history: MangaHistory?,
val isIncognitoMode: Boolean,
) {
val isValid: Boolean
get() = totalChapters >= 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HistoryInfo
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (history != other.history) return false
if (isIncognitoMode != other.isIncognitoMode) return false
return true
}
override fun hashCode(): Int {
var result = totalChapters
result = 31 * result + currentChapter
result = 31 * result + (history?.hashCode() ?: 0)
result = 31 * result + isIncognitoMode.hashCode()
return result
}
}
fun HistoryInfo(
manga: Manga?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.getChapters(branch)
return HistoryInfo(
totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {
-1
},
history = history,
isIncognitoMode = isIncognitoMode,
)
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter
fun MangaChapter.toListItem(
isCurrent: Boolean,
isUnread: Boolean,
isNew: Boolean,
isDownloaded: Boolean,
): ChapterListItem {
var flags = 0
if (isCurrent) flags = flags or FLAG_CURRENT
if (isUnread) flags = flags or FLAG_UNREAD
if (isNew) flags = flags or FLAG_NEW
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
return ChapterListItem(
chapter = this,
flags = flags,
uploadDateMs = uploadDate,
)
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaBranch(
val name: String?,
val count: Int,
val isSelected: Boolean,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaBranch
if (name != other.name) return false
if (count != other.count) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + count
result = 31 * result + isSelected.hashCode()
return result
}
override fun toString(): String {
return "$name: $count"
}
}

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
fun scrobblingInfoAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
) = adapterDelegateViewBinding<ScrobblingInfo, ScrobblingInfo, ItemScrobblingInfoBinding>(
{ layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
ScrobblingInfoBottomSheet.show(fragmentManager, bindingAdapterPosition)
}
bind {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
binding.textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.scrobbler.iconResId, 0)
binding.ratingBar.rating = item.rating * binding.ratingBar.numStars
binding.textViewStatus.text = item.status?.let {
context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,169 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.content.Intent
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblingInfoBottomSheet :
BaseBottomSheet<SheetScrobblingBinding>(),
AdapterView.OnItemSelectedListener,
RatingBar.OnRatingBarChangeListener,
View.OnClickListener,
PopupMenu.OnMenuItemClickListener {
private val viewModel by activityViewModels<DetailsViewModel>()
private var scrobblerIndex: Int = -1
@Inject
lateinit var coil: ImageLoader
private var menu: PopupMenu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
return SheetScrobblingBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.onError.observe(viewLifecycleOwner) {
Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT)
.show()
}
binding.spinnerStatus.onItemSelectedListener = this
binding.ratingBar.onRatingBarChangeListener = this
binding.buttonMenu.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
menu = PopupMenu(binding.root.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling)
setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
}
}
override fun onDestroyView() {
super.onDestroyView()
menu = null
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = requireViewBinding().ratingBar.rating / requireViewBinding().ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition),
)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_menu -> menu?.show()
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle())
}
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
val scrobbling = scrobblings.getOrNull(scrobblerIndex)
if (scrobbling == null) {
dismissAllowingStateLoss()
return
}
requireViewBinding().textViewTitle.text = scrobbling.title
requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars
requireViewBinding().textViewDescription.text = scrobbling.description
requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
requireViewBinding().imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
enqueueWith(coil)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser)),
)
}
R.id.action_unregister -> {
viewModel.unregisterScrobbling(scrobblerIndex)
dismiss()
}
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
val scrobblerService = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss()
}
}
return true
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
private const val ARG_INDEX = "index"
fun show(fm: FragmentManager, index: Int) = ScrobblingInfoBottomSheet().withArgs(1) {
putInt(ARG_INDEX, index)
}.show(fm, TAG)
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class ScrobblingItemDecoration() : RecyclerView.ItemDecoration() {
private var spacing: Int = -1
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
if (spacing == -1) {
spacing = parent.context.resources.getDimensionPixelOffset(R.dimen.scrobbling_list_spacing)
}
outRect.set(0, spacing, 0, 0)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
) : AsyncListDifferDelegationAdapter<ScrobblingInfo>(DiffCallback()) {
init {
delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager))
}
private class DiffCallback : DiffUtil.ItemCallback<ScrobblingInfo>() {
override fun areItemsTheSame(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Boolean {
return oldItem.scrobbler == newItem.scrobbler
}
override fun areContentsTheSame(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any {
return Unit
}
}
}