New download dialog
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 676
|
||||
versionName = '7.6.3'
|
||||
versionCode = 680
|
||||
versionName = '7.7-a1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
|
||||
@@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable
|
||||
import android.graphics.drawable.shapes.RoundRectShape
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Checkable
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.core.widget.TextViewCompat
|
||||
@@ -23,6 +25,7 @@ import com.google.android.material.ripple.RippleUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
||||
@@ -32,7 +35,7 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
) : LinearLayout(context, attrs, defStyleAttr), Checkable {
|
||||
|
||||
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
@@ -48,6 +51,12 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
binding.subtitle.textAndVisible = value
|
||||
}
|
||||
|
||||
var isButtonEnabled: Boolean
|
||||
get() = binding.button.isEnabled
|
||||
set(value) {
|
||||
binding.button.isEnabled = value
|
||||
}
|
||||
|
||||
init {
|
||||
var textColors: ColorStateList? = null
|
||||
context.withStyledAttributes(
|
||||
@@ -68,7 +77,7 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
|
||||
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
|
||||
binding.title.text = getText(R.styleable.TwoLinesItemView_title)
|
||||
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
|
||||
binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle)
|
||||
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
|
||||
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
|
||||
TextViewCompat.setTextAppearance(
|
||||
@@ -79,6 +88,10 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
binding.subtitle,
|
||||
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false)
|
||||
val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button)
|
||||
binding.button.setImageDrawable(button)
|
||||
binding.button.isVisible = button != null
|
||||
}
|
||||
if (textColors == null) {
|
||||
textColors = binding.title.textColors
|
||||
@@ -88,6 +101,16 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
ImageViewCompat.setImageTintList(binding.icon, textColors)
|
||||
}
|
||||
|
||||
override fun isChecked() = binding.icon.isChecked
|
||||
|
||||
override fun toggle() = binding.icon.toggle()
|
||||
|
||||
override fun setChecked(checked: Boolean) {
|
||||
binding.icon.isChecked = checked
|
||||
}
|
||||
|
||||
fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener)
|
||||
|
||||
fun setIconResource(@DrawableRes resId: Int) {
|
||||
binding.icon.setImageResource(resId)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import java.io.Serializable
|
||||
import java.util.EnumSet
|
||||
|
||||
|
||||
// https://issuetracker.google.com/issues/240585930
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
|
||||
@@ -84,3 +85,24 @@ fun <T> SavedStateHandle.require(key: String): T {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
}
|
||||
}
|
||||
|
||||
fun Parcelable.marshall(): ByteArray {
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
this.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Parcelable> Parcelable.Creator<T>.unmarshall(bytes: ByteArray): T {
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.unmarshall(bytes, 0, bytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
createFromParcel(parcel)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class ZipOutput(
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
// FIXME: Deflater has been closed
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -88,6 +88,7 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
|
||||
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
@@ -195,6 +196,7 @@ class DetailsActivity :
|
||||
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
|
||||
.observeEvent(this, DownloadStartedObserver(viewBinding.scrollView))
|
||||
|
||||
DownloadDialogFragment.registerCallback(this, viewBinding.scrollView)
|
||||
menuProvider = DetailsMenuProvider(
|
||||
activity = this,
|
||||
viewModel = viewModel,
|
||||
@@ -210,7 +212,10 @@ class DetailsActivity :
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
R.id.chip_branch -> showBranchPopupMenu(v)
|
||||
R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider)
|
||||
R.id.button_download -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
DownloadDialogFragment.show(supportFragmentManager, listOf(manga))
|
||||
}
|
||||
|
||||
R.id.chip_author -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
|
||||
@@ -19,9 +19,8 @@ import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
@@ -31,7 +30,7 @@ class DetailsMenuProvider(
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val snackbarHost: View,
|
||||
private val appShortcutManager: AppShortcutManager,
|
||||
) : MenuProvider, OnListItemClickListener<DownloadOption> {
|
||||
) : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
@@ -75,7 +74,7 @@ class DetailsMenuProvider(
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
DownloadDialogHelper(snackbarHost, viewModel).show(this)
|
||||
DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value))
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
@@ -129,17 +128,4 @@ class DetailsMenuProvider(
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DownloadOption, view: View) {
|
||||
val chaptersIds: Set<Long>? = when (item) {
|
||||
is DownloadOption.WholeManga -> null
|
||||
is DownloadOption.SelectionHint -> {
|
||||
viewModel.startChaptersSelection()
|
||||
return
|
||||
}
|
||||
|
||||
else -> item.chaptersIds
|
||||
}
|
||||
viewModel.download(chaptersIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
class DownloadDialogHelper(
|
||||
private val host: View,
|
||||
private val viewModel: DetailsViewModel,
|
||||
) {
|
||||
|
||||
fun show(callback: OnListItemClickListener<DownloadOption>) {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
val allChapters = viewModel.manga.value?.chapters ?: return
|
||||
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
|
||||
val history = viewModel.history.value
|
||||
|
||||
val options = buildList {
|
||||
add(DownloadOption.WholeManga(allChapters.ids()))
|
||||
if (branch != null && branchChapters.isNotEmpty()) {
|
||||
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
|
||||
}
|
||||
|
||||
if (history != null) {
|
||||
val unreadChapters = branchChapters.dropWhile { it.id != history.chapterId }
|
||||
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
|
||||
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
|
||||
if (unreadChapters.size > 5) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
|
||||
if (unreadChapters.size > 10) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (branchChapters.size > 5) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
|
||||
if (branchChapters.size > 10) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
add(DownloadOption.SelectionHint())
|
||||
}
|
||||
var dialog: DialogInterface? = null
|
||||
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
|
||||
callback.onItemClick(item, host)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
dialog = buildAlertDialog(host.context) {
|
||||
setCancelable(true)
|
||||
setTitle(R.string.download)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setNeutralButton(R.string.settings) { _, _ ->
|
||||
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
|
||||
}
|
||||
setRecyclerViewList(options, downloadOptionAD(listener))
|
||||
}.also { it.show() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
data class ChapterSelectOptions(
|
||||
val wholeManga: ChaptersSelectMacro.WholeManga,
|
||||
val wholeBranch: ChaptersSelectMacro.WholeBranch?,
|
||||
val firstChapters: ChaptersSelectMacro.FirstChapters?,
|
||||
val unreadChapters: ChaptersSelectMacro.UnreadChapters?,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.LongLongMap
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
|
||||
interface ChaptersSelectMacro {
|
||||
|
||||
fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>?
|
||||
|
||||
class WholeManga(
|
||||
val chaptersCount: Int,
|
||||
) : ChaptersSelectMacro {
|
||||
|
||||
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>? = null
|
||||
}
|
||||
|
||||
class WholeBranch(
|
||||
val branches: Map<String?, Int>,
|
||||
val selectedBranch: String?,
|
||||
) : ChaptersSelectMacro {
|
||||
|
||||
val chaptersCount: Int = branches[selectedBranch] ?: 0
|
||||
|
||||
override fun getChaptersIds(
|
||||
mangaId: Long,
|
||||
chapters: List<MangaChapter>
|
||||
): Set<Long> = chapters.mapNotNullToSet { c ->
|
||||
if (c.branch == selectedBranch) {
|
||||
c.id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun copy(branch: String?) = WholeBranch(branches, branch)
|
||||
}
|
||||
|
||||
class FirstChapters(
|
||||
val chaptersCount: Int,
|
||||
val maxAvailableCount: Int,
|
||||
val branch: String?,
|
||||
) : ChaptersSelectMacro {
|
||||
|
||||
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long> {
|
||||
val result = ArraySet<Long>(chaptersCount)
|
||||
for (c in chapters) {
|
||||
if (c.branch == branch) {
|
||||
result.add(c.id)
|
||||
if (result.size >= chaptersCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun copy(count: Int) = FirstChapters(count, maxAvailableCount, branch)
|
||||
}
|
||||
|
||||
class UnreadChapters(
|
||||
val chaptersCount: Int,
|
||||
val maxAvailableCount: Int,
|
||||
private val currentChaptersIds: LongLongMap,
|
||||
) : ChaptersSelectMacro {
|
||||
|
||||
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long>? {
|
||||
if (chapters.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id)
|
||||
var branch: String? = null
|
||||
var isAdding = false
|
||||
val result = ArraySet<Long>(chaptersCount)
|
||||
for (c in chapters) {
|
||||
if (!isAdding) {
|
||||
if (c.id == currentChapterId) {
|
||||
branch = c.branch
|
||||
isAdding = true
|
||||
}
|
||||
}
|
||||
if (isAdding) {
|
||||
if (c.branch == branch) {
|
||||
result.add(c.id)
|
||||
if (result.size >= chaptersCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun copy(count: Int) = UnreadChapters(count, maxAvailableCount, currentChaptersIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
|
||||
class DestinationsAdapter(context: Context, dataset: List<DirectoryModel>) :
|
||||
ArrayAdapter<DirectoryModel>(context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, dataset) {
|
||||
|
||||
init {
|
||||
setDropDownViewResource(R.layout.item_storage_config)
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(parent.context)
|
||||
.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false)
|
||||
val item = getItem(position) ?: return view
|
||||
view.findViewById<TextView>(android.R.id.text1).text = item.title ?: view.context.getString(item.titleRes)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_storage_config, parent, false)
|
||||
val item = getItem(position) ?: return view
|
||||
val binding =
|
||||
view.tag as? ItemStorageConfigBinding ?: ItemStorageConfigBinding.bind(view).also { view.tag = it }
|
||||
binding.imageViewRemove.isVisible = false
|
||||
binding.textViewTitle.text = item.title ?: view.context.getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.path
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Spinner
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToArray
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogDownloadBinding
|
||||
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), View.OnClickListener {
|
||||
|
||||
private val viewModel by viewModels<DownloadDialogViewModel>()
|
||||
private var optionViews: Array<out TwoLinesItemView>? = null
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
|
||||
DialogDownloadBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setCancelable(true)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogDownloadBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
optionViews = arrayOf(
|
||||
binding.optionWholeManga,
|
||||
binding.optionWholeBranch,
|
||||
binding.optionFirstChapters,
|
||||
binding.optionUnreadChapters,
|
||||
).onEach {
|
||||
it.setOnClickListener(this)
|
||||
it.setOnButtonClickListener(this)
|
||||
}
|
||||
binding.buttonCancel.setOnClickListener(this)
|
||||
binding.buttonConfirm.setOnClickListener(this)
|
||||
binding.textViewMore.setOnClickListener(this)
|
||||
|
||||
binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title }
|
||||
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
|
||||
viewModel.onScheduled.observeEvent(viewLifecycleOwner, this::onDownloadScheduled)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||
viewModel.defaultFormat.observe(viewLifecycleOwner, this::onDefaultFormatChanged)
|
||||
viewModel.availableDestinations.observe(viewLifecycleOwner, this::onDestinationsChanged)
|
||||
viewModel.chaptersSelectOptions.observe(viewLifecycleOwner, this::onChapterSelectOptionsChanged)
|
||||
viewModel.isOptionsLoading.observe(viewLifecycleOwner, binding.progressBar::showOrHide)
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
showMoreOptions(requireViewBinding().textViewMore.isChecked)
|
||||
setCheckedOption(
|
||||
savedInstanceState?.getInt(KEY_CHECKED_OPTION, R.id.option_whole_manga) ?: R.id.option_whole_manga,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
optionViews?.find { it.isChecked }?.let {
|
||||
outState.putInt(KEY_CHECKED_OPTION, it.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
optionViews = null
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> dialog?.cancel()
|
||||
R.id.button_confirm -> viewBinding?.run {
|
||||
val options = viewModel.chaptersSelectOptions.value
|
||||
viewModel.confirm(
|
||||
startNow = switchStart.isChecked,
|
||||
chaptersMacro = when {
|
||||
optionWholeManga.isChecked -> options.wholeManga
|
||||
optionWholeBranch.isChecked -> options.wholeBranch ?: return@run
|
||||
optionFirstChapters.isChecked -> options.firstChapters ?: return@run
|
||||
optionUnreadChapters.isChecked -> options.unreadChapters ?: return@run
|
||||
else -> return@run
|
||||
},
|
||||
format = DownloadFormat.entries.getOrNull(spinnerFormat.selectedItemPosition),
|
||||
destination = viewModel.availableDestinations.value.getOrNull(spinnerDestination.selectedItemPosition),
|
||||
)
|
||||
}
|
||||
|
||||
R.id.textView_more -> {
|
||||
val binding = viewBinding ?: return
|
||||
binding.textViewMore.toggle()
|
||||
showMoreOptions(binding.textViewMore.isChecked)
|
||||
}
|
||||
|
||||
R.id.button -> when (v.parentView?.id ?: return) {
|
||||
R.id.option_whole_branch -> showBranchSelection(v)
|
||||
R.id.option_first_chapters -> showFirstChaptersCountSelection(v)
|
||||
R.id.option_unread_chapters -> showUnreadChaptersCountSelection(v)
|
||||
}
|
||||
|
||||
else -> if (v is TwoLinesItemView) {
|
||||
setCheckedOption(v.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(e.getDisplayMessage(resources))
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(value: Boolean) {
|
||||
with(requireViewBinding()) {
|
||||
buttonConfirm.isEnabled = !value
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDefaultFormatChanged(format: DownloadFormat?) {
|
||||
val spinner = viewBinding?.spinnerFormat ?: return
|
||||
spinner.setSelection(format?.ordinal ?: Spinner.INVALID_POSITION)
|
||||
}
|
||||
|
||||
private fun onDestinationsChanged(directories: List<DirectoryModel>) {
|
||||
viewBinding?.spinnerDestination?.run {
|
||||
adapter = DestinationsAdapter(context, directories)
|
||||
setSelection(directories.indexOfFirst { it.isChecked })
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChapterSelectOptionsChanged(options: ChapterSelectOptions) {
|
||||
with(viewBinding ?: return) {
|
||||
// Whole manga
|
||||
optionWholeManga.subtitle = if (options.wholeManga.chaptersCount > 0) {
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
options.wholeManga.chaptersCount,
|
||||
options.wholeManga.chaptersCount,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// All chapters for branch
|
||||
optionWholeBranch.isVisible = options.wholeBranch != null
|
||||
options.wholeBranch?.let {
|
||||
optionWholeBranch.title = resources.getString(
|
||||
R.string.download_option_all_chapters,
|
||||
it.selectedBranch,
|
||||
)
|
||||
optionWholeBranch.subtitle = if (it.chaptersCount > 0) {
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
it.chaptersCount,
|
||||
it.chaptersCount,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
// First N chapters
|
||||
optionFirstChapters.isVisible = options.firstChapters != null
|
||||
options.firstChapters?.let {
|
||||
optionFirstChapters.title = resources.getString(
|
||||
R.string.download_option_first_n_chapters,
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
it.chaptersCount,
|
||||
it.chaptersCount,
|
||||
),
|
||||
)
|
||||
optionFirstChapters.subtitle = it.branch
|
||||
}
|
||||
// Next N unread chapters
|
||||
optionUnreadChapters.isVisible = options.unreadChapters != null
|
||||
options.unreadChapters?.let {
|
||||
optionUnreadChapters.title = if (it.chaptersCount == Int.MAX_VALUE) {
|
||||
resources.getString(R.string.download_option_all_unread)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.download_option_next_unread_n_chapters,
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
it.chaptersCount,
|
||||
it.chaptersCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDownloadScheduled(isStarted: Boolean) {
|
||||
val bundle = Bundle(1)
|
||||
bundle.putBoolean(ARG_STARTED, isStarted)
|
||||
setFragmentResult(RESULT_KEY, bundle)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun showMoreOptions(isVisible: Boolean) = viewBinding?.apply {
|
||||
cardFormat.isVisible = isVisible
|
||||
textViewFormat.isVisible = isVisible
|
||||
cardDestination.isVisible = isVisible
|
||||
textViewDestination.isVisible = isVisible
|
||||
}
|
||||
|
||||
private fun setCheckedOption(id: Int) {
|
||||
for (optionView in optionViews ?: return) {
|
||||
optionView.isChecked = id == optionView.id
|
||||
optionView.isButtonEnabled = optionView.isChecked
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBranchSelection(v: View) {
|
||||
val option = viewModel.chaptersSelectOptions.value.wholeBranch ?: return
|
||||
val branches = option.branches.keys.toList()
|
||||
if (branches.size <= 1) {
|
||||
return
|
||||
}
|
||||
val menu = PopupMenu(v.context, v)
|
||||
for ((i, branch) in branches.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, branch ?: getString(R.string.unknown))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setSelectedBranch(branches.getOrNull(it.order))
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun showFirstChaptersCountSelection(v: View) {
|
||||
val option = viewModel.chaptersSelectOptions.value.firstChapters ?: return
|
||||
val menu = PopupMenu(v.context, v)
|
||||
chaptersCount(option.maxAvailableCount).forEach { i ->
|
||||
menu.menu.add(i.format())
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setFirstChaptersCount(
|
||||
it.title?.toString()?.toIntOrNull() ?: return@setOnMenuItemClickListener false,
|
||||
)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun showUnreadChaptersCountSelection(v: View) {
|
||||
val option = viewModel.chaptersSelectOptions.value.unreadChapters ?: return
|
||||
val menu = PopupMenu(v.context, v)
|
||||
chaptersCount(option.maxAvailableCount).forEach { i ->
|
||||
menu.menu.add(i.format())
|
||||
}
|
||||
menu.menu.add(getString(R.string.chapters_all))
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setUnreadChaptersCount(it.title?.toString()?.toIntOrNull() ?: Int.MAX_VALUE)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun chaptersCount(max: Int) = sequence {
|
||||
yield(1)
|
||||
var seed = 5
|
||||
var step = 5
|
||||
while (seed + step <= max) {
|
||||
yield(seed)
|
||||
step = when {
|
||||
seed < 20 -> 5
|
||||
seed < 60 -> 10
|
||||
else -> 20
|
||||
}
|
||||
seed += step
|
||||
}
|
||||
if (seed < max) {
|
||||
yield(max)
|
||||
}
|
||||
}
|
||||
|
||||
private class SnackbarResultListener(private val host: View) : FragmentResultListener {
|
||||
|
||||
override fun onFragmentResult(requestKey: String, result: Bundle) {
|
||||
val isStarted = result.getBoolean(ARG_STARTED, true)
|
||||
val snackbar = Snackbar.make(
|
||||
host,
|
||||
if (isStarted) R.string.download_started else R.string.download_added,
|
||||
Snackbar.LENGTH_LONG,
|
||||
)
|
||||
(host.context.findActivity() as? BottomNavOwner)?.let {
|
||||
snackbar.anchorView = it.bottomNav
|
||||
}
|
||||
snackbar.setAction(R.string.details) {
|
||||
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "DownloadDialogFragment"
|
||||
private const val RESULT_KEY = "DOWNLOAD_STARTED"
|
||||
private const val ARG_STARTED = "started"
|
||||
private const val KEY_CHECKED_OPTION = "checked_opt"
|
||||
const val ARG_MANGA = "manga"
|
||||
|
||||
fun show(fm: FragmentManager, manga: Collection<Manga>) = DownloadDialogFragment().withArgs(1) {
|
||||
putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) })
|
||||
}.showDistinct(fm, TAG)
|
||||
|
||||
fun registerCallback(activity: FragmentActivity, snackbarHost: View) =
|
||||
activity.supportFragmentManager.setFragmentResultListener(
|
||||
RESULT_KEY,
|
||||
activity,
|
||||
SnackbarResultListener(snackbarHost),
|
||||
)
|
||||
|
||||
fun registerCallback(fragment: Fragment, snackbarHost: View) =
|
||||
fragment.childFragmentManager.setFragmentResultListener(
|
||||
RESULT_KEY,
|
||||
fragment.viewLifecycleOwner,
|
||||
SnackbarResultListener(snackbarHost),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.MutableLongLongMap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadTask
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DownloadDialogViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val scheduler: DownloadWorker.Scheduler,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
|
||||
it.manga
|
||||
}
|
||||
private val mangaDetails = SuspendLazy {
|
||||
coroutineScope {
|
||||
manga.map { m ->
|
||||
async { m.getDetails() }
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
val onScheduled = MutableEventFlow<Boolean>()
|
||||
val defaultFormat = MutableStateFlow<DownloadFormat?>(null)
|
||||
val availableDestinations = MutableStateFlow(listOf(defaultDestination()))
|
||||
val chaptersSelectOptions = MutableStateFlow(
|
||||
ChapterSelectOptions(
|
||||
wholeManga = ChaptersSelectMacro.WholeManga(0),
|
||||
wholeBranch = null,
|
||||
firstChapters = null,
|
||||
unreadChapters = null,
|
||||
),
|
||||
)
|
||||
val isOptionsLoading = MutableStateFlow(true)
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
defaultFormat.value = settings.preferredDownloadFormat
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadAvailableOptions()
|
||||
} finally {
|
||||
isOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
loadAvailableDestinations()
|
||||
}
|
||||
|
||||
fun confirm(
|
||||
startNow: Boolean,
|
||||
chaptersMacro: ChaptersSelectMacro,
|
||||
format: DownloadFormat?,
|
||||
destination: DirectoryModel?,
|
||||
) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val tasks = mangaDetails.get().map { m ->
|
||||
val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" }
|
||||
mangaDataRepository.storeManga(m)
|
||||
DownloadTask(
|
||||
mangaId = m.id,
|
||||
isPaused = !startNow,
|
||||
isSilent = false,
|
||||
chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray(),
|
||||
destination = destination?.file,
|
||||
format = format,
|
||||
)
|
||||
}
|
||||
scheduler.schedule(tasks)
|
||||
onScheduled.call(startNow)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
val snapshot = chaptersSelectOptions.value
|
||||
chaptersSelectOptions.value = snapshot.copy(
|
||||
wholeBranch = snapshot.wholeBranch?.copy(branch),
|
||||
)
|
||||
}
|
||||
|
||||
fun setFirstChaptersCount(count: Int) {
|
||||
val snapshot = chaptersSelectOptions.value
|
||||
chaptersSelectOptions.value = snapshot.copy(
|
||||
firstChapters = snapshot.firstChapters?.copy(count),
|
||||
)
|
||||
}
|
||||
|
||||
fun setUnreadChaptersCount(count: Int) {
|
||||
val snapshot = chaptersSelectOptions.value
|
||||
chaptersSelectOptions.value = snapshot.copy(
|
||||
unreadChapters = snapshot.unreadChapters?.copy(count),
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultDestination() = DirectoryModel(
|
||||
title = null,
|
||||
titleRes = R.string.system_default,
|
||||
file = null,
|
||||
isRemovable = false,
|
||||
isChecked = true,
|
||||
isAvailable = true,
|
||||
)
|
||||
|
||||
private suspend fun loadAvailableOptions() {
|
||||
val details = mangaDetails.get()
|
||||
var totalChapters = 0
|
||||
val branches = ArrayMap<String?, Int>()
|
||||
var maxChapters = 0
|
||||
var maxUnreadChapters = 0
|
||||
val preferredBranches = ArraySet<String?>(details.size)
|
||||
val currentChaptersIds = MutableLongLongMap(details.size)
|
||||
|
||||
details.forEach { m ->
|
||||
val history = historyRepository.getOne(m)
|
||||
if (history != null) {
|
||||
currentChaptersIds[m.id] = history.chapterId
|
||||
val unreadChaptersCount = m.chapters?.dropWhile { it.id != history.chapterId }.sizeOrZero()
|
||||
maxUnreadChapters = maxOf(maxUnreadChapters, unreadChaptersCount)
|
||||
} else {
|
||||
maxUnreadChapters = maxOf(maxUnreadChapters, m.chapters.sizeOrZero())
|
||||
}
|
||||
maxChapters = maxOf(maxChapters, m.chapters.sizeOrZero())
|
||||
preferredBranches.add(m.getPreferredBranch(history))
|
||||
m.chapters?.forEach { c ->
|
||||
totalChapters++
|
||||
branches.increment(c.branch)
|
||||
}
|
||||
}
|
||||
val defaultBranch = preferredBranches.firstOrNull()
|
||||
chaptersSelectOptions.value = ChapterSelectOptions(
|
||||
wholeManga = ChaptersSelectMacro.WholeManga(totalChapters),
|
||||
wholeBranch = if (branches.size > 1) {
|
||||
ChaptersSelectMacro.WholeBranch(
|
||||
branches = branches,
|
||||
selectedBranch = defaultBranch,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
firstChapters = if (maxChapters > 0) {
|
||||
ChaptersSelectMacro.FirstChapters(
|
||||
chaptersCount = minOf(5, maxChapters),
|
||||
maxAvailableCount = maxChapters,
|
||||
branch = defaultBranch,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
unreadChapters = if (currentChaptersIds.isNotEmpty()) {
|
||||
ChaptersSelectMacro.UnreadChapters(
|
||||
chaptersCount = minOf(5, maxUnreadChapters),
|
||||
maxAvailableCount = maxUnreadChapters,
|
||||
currentChaptersIds = currentChaptersIds,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadAvailableDestinations() = launchJob(Dispatchers.Default) {
|
||||
val defaultDir = manga.mapToSet {
|
||||
localMangaRepository.getOutputDir(it, null)
|
||||
}.singleOrNull()
|
||||
val dirs = localStorageManager.getWriteableDirs()
|
||||
availableDestinations.value = buildList(dirs.size + 1) {
|
||||
if (defaultDir == null) {
|
||||
add(defaultDestination())
|
||||
} else if (defaultDir !in dirs) {
|
||||
add(
|
||||
DirectoryModel(
|
||||
title = localStorageManager.getDirectoryDisplayName(defaultDir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = defaultDir,
|
||||
isChecked = true,
|
||||
isAvailable = true,
|
||||
isRemovable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
dirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = localStorageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = dir == defaultDir,
|
||||
isAvailable = true,
|
||||
isRemovable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Manga.getDetails(): Manga = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(source).getDetails(this)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(this)
|
||||
|
||||
private fun <T> MutableMap<T, Int>.increment(key: T) {
|
||||
put(key, getOrDefault(key, 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
sealed interface DownloadOption {
|
||||
|
||||
val chaptersIds: Set<Long>
|
||||
|
||||
@get:DrawableRes
|
||||
val iconResId: Int
|
||||
|
||||
val chaptersCount: Int
|
||||
get() = chaptersIds.size
|
||||
|
||||
fun getLabel(resources: Resources): CharSequence
|
||||
|
||||
class AllChapters(
|
||||
val branch: String,
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_select_group
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_all_chapters, branch)
|
||||
}
|
||||
}
|
||||
|
||||
class WholeManga(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_whole_manga)
|
||||
}
|
||||
}
|
||||
|
||||
class FirstChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_start
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_first_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AllUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
val branch: String?,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_end
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return if (branch == null) {
|
||||
resources.getString(R.string.download_option_all_unread)
|
||||
} else {
|
||||
resources.getString(R.string.download_option_all_unread_b, branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NextUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_next
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_next_unread_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SelectionHint : DownloadOption {
|
||||
|
||||
override val chaptersIds: Set<Long> = emptySet()
|
||||
override val iconResId = R.drawable.ic_tap
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_manual_selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
|
||||
|
||||
fun downloadOptionAD(
|
||||
onClickListener: OnListItemClickListener<DownloadOption>,
|
||||
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
|
||||
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
with(binding.root) {
|
||||
title = item.getLabel(resources)
|
||||
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
item.chaptersCount,
|
||||
item.chaptersCount,
|
||||
)
|
||||
setIconResource(item.iconResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
|
||||
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
|
||||
val chapterIds = workScheduler.getTask(workId)?.chaptersIds
|
||||
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
|
||||
|
||||
suspend fun mapChapters(): List<DownloadChapter> {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu.download.ui.worker
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.work.Data
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import java.io.File
|
||||
|
||||
@Parcelize
|
||||
class DownloadTask(
|
||||
val mangaId: Long,
|
||||
val isPaused: Boolean,
|
||||
val isSilent: Boolean,
|
||||
val chaptersIds: LongArray?,
|
||||
val destination: File?,
|
||||
val format: DownloadFormat?,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(data: Data) : this(
|
||||
mangaId = data.getLong(MANGA_ID, 0L),
|
||||
isPaused = data.getBoolean(START_PAUSED, false),
|
||||
isSilent = data.getBoolean(IS_SILENT, false),
|
||||
chaptersIds = data.getLongArray(CHAPTERS)?.takeUnless(LongArray::isEmpty),
|
||||
destination = data.getString(DESTINATION)?.let { File(it) },
|
||||
format = data.getString(FORMAT)?.let { DownloadFormat.entries.find(it) },
|
||||
)
|
||||
|
||||
fun toData(): Data = Data.Builder()
|
||||
.putLong(MANGA_ID, mangaId)
|
||||
.putBoolean(START_PAUSED, isPaused)
|
||||
.putBoolean(IS_SILENT, isSilent)
|
||||
.putLongArray(CHAPTERS, chaptersIds ?: LongArray(0))
|
||||
.putString(DESTINATION, destination?.path)
|
||||
.putString(FORMAT, format?.name)
|
||||
.build()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DownloadTask
|
||||
|
||||
if (mangaId != other.mangaId) return false
|
||||
if (isPaused != other.isPaused) return false
|
||||
if (isSilent != other.isSilent) return false
|
||||
if (!(chaptersIds contentEquals other.chaptersIds)) return false
|
||||
if (destination != other.destination) return false
|
||||
if (format != other.format) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = mangaId.hashCode()
|
||||
result = 31 * result + isPaused.hashCode()
|
||||
result = 31 * result + isSilent.hashCode()
|
||||
result = 31 * result + (chaptersIds?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (destination?.hashCode() ?: 0)
|
||||
result = 31 * result + (format?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val MANGA_ID = "manga_id"
|
||||
const val IS_SILENT = "silent"
|
||||
const val START_PAUSED = "paused"
|
||||
const val CHAPTERS = "chapters"
|
||||
const val DESTINATION = "dest"
|
||||
const val FORMAT = "format"
|
||||
}
|
||||
}
|
||||
@@ -105,10 +105,8 @@ class DownloadWorker @AssistedInject constructor(
|
||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val notificationFactory = notificationFactoryFactory.create(
|
||||
uuid = params.id,
|
||||
isSilent = params.inputData.getBoolean(IS_SILENT, false),
|
||||
)
|
||||
private val task = DownloadTask(params.inputData)
|
||||
private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent)
|
||||
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
|
||||
|
||||
@@ -122,18 +120,16 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
setForeground(getForegroundInfo())
|
||||
val mangaId = inputData.getLong(MANGA_ID, 0L)
|
||||
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
|
||||
val manga = mangaDataRepository.findMangaById(task.mangaId) ?: return Result.failure()
|
||||
publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it })
|
||||
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||
val downloadedIds = getDoneChapters(manga)
|
||||
return try {
|
||||
val pausingHandle = PausingHandle()
|
||||
if (inputData.getBoolean(START_PAUSED, false)) {
|
||||
if (task.isPaused) {
|
||||
pausingHandle.pause()
|
||||
}
|
||||
withContext(pausingHandle) {
|
||||
downloadMangaImpl(manga, chaptersIds, downloadedIds)
|
||||
downloadMangaImpl(manga, task, downloadedIds)
|
||||
}
|
||||
Result.success(currentState.toWorkData())
|
||||
} catch (e: CancellationException) {
|
||||
@@ -174,7 +170,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
private suspend fun downloadMangaImpl(
|
||||
subject: Manga,
|
||||
includedIds: LongArray?,
|
||||
task: DownloadTask,
|
||||
excludedIds: Set<Long>,
|
||||
) {
|
||||
var manga = subject
|
||||
@@ -187,7 +183,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
PausingReceiver.createIntentFilter(id),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
val destination = localMangaRepository.getOutputDir(manga)
|
||||
val destination = localMangaRepository.getOutputDir(manga, task.destination)
|
||||
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
||||
var output: LocalMangaOutput? = null
|
||||
try {
|
||||
@@ -197,7 +193,11 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat)
|
||||
output = LocalMangaOutput.getOrCreate(
|
||||
root = destination,
|
||||
manga = mangaDetails,
|
||||
format = task.format ?: settings.preferredDownloadFormat,
|
||||
)
|
||||
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
|
||||
if (coverUrl.isNotEmpty()) {
|
||||
downloadFile(coverUrl, destination, repo.source).let { file ->
|
||||
@@ -205,7 +205,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
file.deleteAwait()
|
||||
}
|
||||
}
|
||||
val chapters = getChapters(mangaDetails, includedIds)
|
||||
val chapters = getChapters(mangaDetails, task)
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
checkIsPaused()
|
||||
if (chaptersToSkip.remove(chapter.value.id)) {
|
||||
@@ -311,6 +311,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
DOWNLOAD_ERROR_DELAY
|
||||
}
|
||||
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
|
||||
val pausingHandle = PausingHandle.current()
|
||||
if (pausingHandle.skipAllErrors()) {
|
||||
return null
|
||||
}
|
||||
publishState(
|
||||
currentState.copy(
|
||||
isPaused = true,
|
||||
@@ -321,7 +325,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
),
|
||||
)
|
||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
val pausingHandle = PausingHandle.current()
|
||||
pausingHandle.pause()
|
||||
try {
|
||||
pausingHandle.awaitResumed()
|
||||
@@ -404,10 +407,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
private fun getChapters(
|
||||
manga: Manga,
|
||||
includedIds: LongArray?,
|
||||
task: DownloadTask,
|
||||
): List<IndexedValue<MangaChapter>> {
|
||||
val chapters = checkNotNull(manga.chapters) { "Chapters list must not be null" }
|
||||
val chaptersIdsSet = includedIds?.toMutableSet()
|
||||
val chaptersIdsSet = task.chaptersIds?.toMutableSet()
|
||||
val result = ArrayList<IndexedValue<MangaChapter>>((chaptersIdsSet ?: chapters).size)
|
||||
val counters = HashMap<String?, Int>()
|
||||
for (chapter in chapters) {
|
||||
@@ -420,7 +423,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
if (chaptersIdsSet != null) {
|
||||
check(chaptersIdsSet.isEmpty()) {
|
||||
"${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga"
|
||||
"${chaptersIdsSet.size} of ${task.chaptersIds.size} requested chapters not found in manga"
|
||||
}
|
||||
}
|
||||
check(result.isNotEmpty()) { "Chapters list must not be empty" }
|
||||
@@ -435,35 +438,42 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
@Deprecated("")
|
||||
suspend fun schedule(
|
||||
manga: Manga,
|
||||
chaptersIds: Collection<Long>?,
|
||||
chaptersIds: Set<Long>?,
|
||||
isPaused: Boolean,
|
||||
isSilent: Boolean,
|
||||
) {
|
||||
dataRepository.storeManga(manga)
|
||||
val data = Data.Builder()
|
||||
.putLong(MANGA_ID, manga.id)
|
||||
.putBoolean(START_PAUSED, isPaused)
|
||||
.putBoolean(IS_SILENT, isSilent)
|
||||
if (!chaptersIds.isNullOrEmpty()) {
|
||||
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
scheduleImpl(listOf(data.build()))
|
||||
val task = DownloadTask(
|
||||
mangaId = manga.id,
|
||||
isPaused = isPaused,
|
||||
isSilent = isSilent,
|
||||
chaptersIds = chaptersIds?.toLongArray(),
|
||||
destination = null,
|
||||
format = null,
|
||||
)
|
||||
schedule(listOf(task))
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
suspend fun schedule(
|
||||
manga: Collection<Manga>,
|
||||
isPaused: Boolean,
|
||||
) {
|
||||
val data = manga.map {
|
||||
val tasks = manga.map {
|
||||
dataRepository.storeManga(it)
|
||||
Data.Builder()
|
||||
.putLong(MANGA_ID, it.id)
|
||||
.putBoolean(START_PAUSED, isPaused)
|
||||
.build()
|
||||
DownloadTask(
|
||||
mangaId = it.id,
|
||||
isPaused = isPaused,
|
||||
isSilent = false,
|
||||
chaptersIds = null,
|
||||
destination = null,
|
||||
format = null,
|
||||
)
|
||||
}
|
||||
scheduleImpl(data)
|
||||
schedule(tasks)
|
||||
}
|
||||
|
||||
fun observeWorks(): Flow<List<WorkInfo>> = workManager
|
||||
@@ -478,8 +488,8 @@ class DownloadWorker @AssistedInject constructor(
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
|
||||
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||
suspend fun getTask(workId: UUID): DownloadTask? {
|
||||
return workManager.getWorkInputData(workId)?.let { DownloadTask(it) }
|
||||
}
|
||||
|
||||
suspend fun cancel(id: UUID) {
|
||||
@@ -537,18 +547,18 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scheduleImpl(data: Collection<Data>) {
|
||||
if (data.isEmpty()) {
|
||||
suspend fun schedule(tasks: Collection<DownloadTask>) {
|
||||
if (tasks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val constraints = createConstraints()
|
||||
val requests = data.map { inputData ->
|
||||
val requests = tasks.map { task ->
|
||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG)
|
||||
.keepResultsForAtLeast(30, TimeUnit.DAYS)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.setInputData(task.toData())
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
}
|
||||
@@ -567,10 +577,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
const val DOWNLOAD_ERROR_DELAY = 2_000L
|
||||
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
|
||||
const val SLOWDOWN_DELAY = 200L
|
||||
const val MANGA_ID = "manga_id"
|
||||
const val CHAPTERS_IDS = "chapters"
|
||||
const val IS_SILENT = "silent"
|
||||
const val START_PAUSED = "paused"
|
||||
const val TAG = "download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
|
||||
fun skipAllErrors(): Boolean = skipAllErrors
|
||||
|
||||
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = false)
|
||||
|
||||
companion object : CoroutineContext.Key<PausingHandle> {
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager
|
||||
@@ -46,7 +45,7 @@ import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.QuickFilterListener
|
||||
@@ -126,6 +125,7 @@ abstract class MangaListFragment :
|
||||
isEnabled = isSwipeRefreshEnabled
|
||||
}
|
||||
addMenuProvider(MangaListMenuProvider(this))
|
||||
DownloadDialogFragment.registerCallback(this, binding.recyclerView)
|
||||
|
||||
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
|
||||
@@ -133,7 +133,6 @@ abstract class MangaListFragment :
|
||||
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -324,11 +323,8 @@ abstract class MangaListFragment :
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
val itemsSnapshot = selectedItems
|
||||
CommonAlertDialogs.showDownloadConfirmation(context ?: return false) { startPaused ->
|
||||
mode?.finish()
|
||||
viewModel.download(itemsSnapshot, isPaused = startPaused)
|
||||
}
|
||||
DownloadDialogFragment.show(childFragmentManager, selectedItems)
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -37,7 +36,6 @@ abstract class MangaListViewModel(
|
||||
key = AppSettings.KEY_GRID_SIZE,
|
||||
valueProducer = { gridSize / 100f },
|
||||
)
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
|
||||
val isIncognitoModeEnabled: Boolean
|
||||
get() = settings.isIncognitoModeEnabled
|
||||
@@ -46,13 +44,6 @@ abstract class MangaListViewModel(
|
||||
|
||||
abstract fun onRetry()
|
||||
|
||||
fun download(items: Set<Manga>, isPaused: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(items, isPaused)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
|
||||
filterNot { it.isNsfw }
|
||||
} else {
|
||||
|
||||
@@ -200,8 +200,8 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
|
||||
|
||||
suspend fun getOutputDir(manga: Manga): File? {
|
||||
val defaultDir = storageManager.getDefaultWriteableDir()
|
||||
suspend fun getOutputDir(manga: Manga, fallback: File?): File? {
|
||||
val defaultDir = fallback ?: storageManager.getDefaultWriteableDir()
|
||||
if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
|
||||
return defaultDir
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/description"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_description_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_read" />
|
||||
@@ -274,7 +274,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/tracking"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/chips_tags" />
|
||||
@@ -343,7 +343,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/related_manga"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_related_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" />
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
android:gravity="center_vertical|start"
|
||||
android:padding="8dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/container_master"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:text="@string/favourites_categories"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" />
|
||||
android:textAppearance="?textAppearanceTitleSmall" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/description"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_description_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_read" />
|
||||
@@ -283,7 +283,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/tracking"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/chips_tags" />
|
||||
@@ -352,7 +352,7 @@
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:text="@string/related_manga"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_related_more"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" />
|
||||
|
||||
224
app/src/main/res/layout/dialog_download.xml
Normal file
224
app/src/main/res/layout/dialog_download.xml
Normal file
@@ -0,0 +1,224 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="?dialogPreferredPadding">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:scrollIndicators="top|bottom"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:paddingHorizontal="@dimen/margin_normal"
|
||||
android:textAppearance="?textAppearanceBody2"
|
||||
tools:text="@tools:sample/lorem[15]" />
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/option_whole_manga"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:checked="true"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
app:icon="?android:listChoiceIndicatorSingle"
|
||||
app:title="@string/download_option_whole_manga"
|
||||
tools:subtitle="@string/no_chapters" />
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/option_whole_branch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:button="@drawable/ic_expand_more"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:visibility="gone"
|
||||
app:icon="?android:listChoiceIndicatorSingle"
|
||||
tools:subtitle="@string/no_chapters"
|
||||
tools:title="@string/download_option_all_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/option_first_chapters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:button="@drawable/ic_expand_more"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:visibility="gone"
|
||||
app:icon="?android:listChoiceIndicatorSingle"
|
||||
tools:title="@string/download_option_first_n_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/option_unread_chapters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:button="@drawable/ic_expand_more"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:visibility="gone"
|
||||
app:icon="?android:listChoiceIndicatorSingle"
|
||||
tools:title="@string/download_option_next_unread_n_chapters"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_normal"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:text="@string/chapter_selection_hint"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switch_start"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:checked="true"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:ellipsize="end"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:text="@string/start_download"
|
||||
android:textAppearance="?attr/textAppearanceButton"
|
||||
android:textColor="?colorOnSurfaceVariant" />
|
||||
|
||||
<CheckedTextView
|
||||
android:id="@+id/textView_more"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:background="@drawable/list_selector"
|
||||
android:checked="false"
|
||||
android:drawableEnd="@drawable/ic_expand_collapse"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:text="@string/more_options"
|
||||
android:textAppearance="?attr/textAppearanceButton"
|
||||
android:textColor="?colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_destination"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:paddingHorizontal="@dimen/margin_normal"
|
||||
android:text="@string/destination_directory"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_destination"
|
||||
style="?materialCardViewOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_destination"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/spinner_height"
|
||||
android:paddingHorizontal="8dp" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_format"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:paddingHorizontal="@dimen/margin_normal"
|
||||
android:text="@string/preferred_download_format"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_format"
|
||||
style="?materialCardViewOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_format"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:entries="@array/download_formats"
|
||||
android:minHeight="@dimen/spinner_height"
|
||||
android:paddingHorizontal="8dp" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
style="?buttonBarStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="?dialogPreferredPadding"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_cancel"
|
||||
style="?buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@android:string/cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_confirm"
|
||||
style="?buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/save" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/button_file"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
tools:subtitle="@string/chapters"
|
||||
tools:title="@string/download_option_whole_manga" />
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:gravity="center_vertical|start"
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
android:gravity="center_vertical|start"
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
|
||||
<Button
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:gravity="center_vertical|start"
|
||||
android:padding="@dimen/grid_spacing"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
android:textAppearance="?textAppearanceTitleSmall"
|
||||
tools:text="@string/genres" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
tools:orientation="horizontal"
|
||||
tools:parentTag="android.widget.LinearLayout">
|
||||
|
||||
<ImageView
|
||||
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="6dp">
|
||||
|
||||
@@ -35,4 +36,15 @@
|
||||
tools:text="@tools:sample/lorem[12]" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:minWidth="?minTouchTargetSize"
|
||||
android:minHeight="?minTouchTargetSize"
|
||||
android:scaleType="center"
|
||||
tools:src="@drawable/ic_expand_more" />
|
||||
</merge>
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
<attr name="icon" />
|
||||
<attr name="titleTextAppearance" />
|
||||
<attr name="subtitleTextAppearance" />
|
||||
<attr name="android:checked" />
|
||||
<attr name="android:button" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="ProgressDrawable">
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
<string name="enable">Enable</string>
|
||||
<string name="no_thanks">No thanks</string>
|
||||
<string name="cancel_all_downloads_confirm">All active downloads will be cancelled, partially downloaded data will be lost</string>
|
||||
<string name="remove_completed_downloads_confirm">Your downloads history will be permanently deleted</string>
|
||||
<string name="remove_completed_downloads_confirm">Your downloads history will be permanently deleted. No downloaded files will be affected</string>
|
||||
<string name="text_downloads_list_holder">You don\'t have any downloads</string>
|
||||
<string name="downloads_resumed">Downloads have been resumed</string>
|
||||
<string name="downloads_paused">Downloads have been paused</string>
|
||||
@@ -742,4 +742,10 @@
|
||||
<string name="save_manga_confirm">Save selected manga? This may consume traffic and disk space</string>
|
||||
<string name="save_manga">Save manga</string>
|
||||
<string name="genre">Genre</string>
|
||||
<string name="download_added">Download added</string>
|
||||
<string name="more_options">More options</string>
|
||||
<string name="destination_directory">Destination directory</string>
|
||||
<string name="chapter_selection_hint">You can select chapters to download by long click on item in the chapter list.</string>
|
||||
<!-- For chapters -->
|
||||
<string name="chapters_all">All</string>
|
||||
</resources>
|
||||
|
||||
@@ -238,10 +238,6 @@
|
||||
|
||||
<style name="TextAppearance.Kotatsu.Menu" parent="TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
<style name="TextAppearance.Kotatsu.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
|
||||
<item name="android:textColor">?android:attr/textColorSecondary</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.Kotatsu.GridTitle" parent="TextAppearance.Material3.TitleSmall" />
|
||||
|
||||
<style name="TextAppearance.Kotatsu.GridTitle.Small" parent="TextAppearance.Material3.TitleSmall">
|
||||
|
||||
Reference in New Issue
Block a user