Multiple sources selection

This commit is contained in:
Koitharu
2024-05-10 17:39:00 +03:00
parent 82684601b7
commit 7c82b4effb
16 changed files with 221 additions and 73 deletions

View File

@@ -18,6 +18,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -150,7 +151,7 @@ class AppShortcutManager @Inject constructor(
.build()
}
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
@@ -163,7 +164,7 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
return ShortcutInfoCompat.Builder(context, source.name)
ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setIcon(icon)

View File

@@ -7,6 +7,7 @@ import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
@@ -19,7 +20,10 @@ import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val defaultRadius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
@@ -32,11 +36,12 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
98,
)
paint.style = Paint.Style.FILL
hasBackground = false
hasBackground = true
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
@@ -45,6 +50,19 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
return item.chapter.id
}
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
if (child is CardView) {
return
}
canvas.drawRoundRect(bounds, radius, radius, paint)
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
@@ -52,16 +70,24 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
bounds: RectF,
state: RecyclerView.State
) {
val radius = if (child is CardView) {
child.radius
} else {
defaultRadius
if (child !is CardView) {
return
}
val radius = child.radius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -97,10 +97,10 @@ class MangaSourcesRepository @Inject constructor(
result
}
suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle {
dao.setEnabled(source.name, isEnabled)
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
setSourcesEnabledImpl(sources, isEnabled)
return ReversibleHandle {
dao.setEnabled(source.name, !isEnabled)
setSourcesEnabledImpl(sources, !isEnabled)
}
}
@@ -171,6 +171,18 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
if (sources.size == 1) { // fast path
dao.setEnabled(sources.first().name, isEnabled)
return
}
db.withTransaction {
for (source in sources) {
dao.setEnabled(source.name, isEnabled)
}
}
}
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)

View File

@@ -4,11 +4,11 @@ import android.content.DialogInterface
import android.content.Intent
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.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
@@ -24,12 +24,14 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
@@ -44,6 +46,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.TipModel
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.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
@@ -56,7 +59,7 @@ class ExploreFragment :
BaseFragment<FragmentExploreBinding>(),
RecyclerViewOwner,
ExploreListEventListener,
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener {
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
@@ -66,6 +69,7 @@ class ExploreFragment :
private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null
private var sourceSelectionController: ListSelectionController? = null
override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView
@@ -79,11 +83,18 @@ class ExploreFragment :
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
startActivity(DetailsActivity.newIntent(view.context, manga))
}
sourceSelectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = SourceSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
with(binding.recyclerView) {
adapter = exploreAdapter
setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(sourceSelectionController).attachToRecyclerView(this)
}
addMenuProvider(ExploreMenuProvider(binding.root.context))
viewModel.content.observe(viewLifecycleOwner) {
@@ -100,6 +111,7 @@ class ExploreFragment :
override fun onDestroyView() {
super.onDestroyView()
sourceSelectionController = null
exploreAdapter = null
}
@@ -147,18 +159,15 @@ class ExploreFragment :
}
override fun onItemClick(item: MangaSourceItem, view: View) {
if (sourceSelectionController?.onItemClick(item.id) == true) {
return
}
val intent = MangaListActivity.newIntent(view.context, item.source)
startActivity(intent)
}
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
return sourceSelectionController?.onItemLongClick(item.id) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -167,6 +176,54 @@ class ExploreFragment :
startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java))
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_source, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val isSingleSelection = controller.count == 1
menu.findItem(R.id.action_settings).isVisible = isSingleSelection
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
return super.onPrepareActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
MangaSource.entries.getOrNull(id.toInt())
}
if (selectedSources.isEmpty()) {
return false
}
when (item.itemId) {
R.id.action_settings -> {
val source = selectedSources.singleOrNull() ?: return false
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
mode.finish()
}
R.id.action_disable -> {
viewModel.disableSources(selectedSources)
mode.finish()
}
R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(source)
}
mode.finish()
}
else -> return false
}
return true
}
private fun onOpenManga(manga: Manga) {
val intent = DetailsActivity.newIntent(context ?: return, manga)
startActivity(intent)
@@ -194,30 +251,4 @@ class ExploreFragment :
.create()
.show()
}
private inner class SourceMenuListener(
private val sourceItem: MangaSourceItem,
) : PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
}
R.id.action_disable -> {
viewModel.disableSource(sourceItem.source)
}
R.id.action_shortcut -> {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(sourceItem.source)
}
}
else -> return false
}
return true
}
}
}

View File

@@ -92,10 +92,11 @@ class ExploreViewModel @Inject constructor(
}
}
fun disableSource(source: MangaSource) {
fun disableSources(sources: Collection<MangaSource>) {
launchJob(Dispatchers.Default) {
val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false)
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false)
val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled
onActionDone.call(ReversibleAction(message, rollback))
}
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.explore.ui
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 androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import com.google.android.material.R as materialR
class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaSourceItem::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
}
}

View File

@@ -8,6 +8,9 @@ data class MangaSourceItem(
val isGrid: Boolean,
) : ListModel {
val id: Long
get() = source.ordinal.toLong()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source
}

View File

@@ -81,7 +81,7 @@ class SearchSuggestionViewModel @Inject constructor(
fun onSourceToggle(source: MangaSource, isEnabled: Boolean) {
launchJob(Dispatchers.Default) {
sourcesRepository.setSourceEnabled(source, isEnabled)
sourcesRepository.setSourcesEnabled(setOf(source), isEnabled)
}
}

View File

@@ -45,7 +45,7 @@ class NewSourcesViewModel @Inject constructor(
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
launchJob(Dispatchers.Default) {
repository.setSourceEnabled(item.source, isEnabled)
repository.setSourcesEnabled(setOf(item.source), isEnabled)
}
}
}

View File

@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
@@ -75,7 +74,7 @@ class SourceSettingsViewModel @Inject constructor(
fun setEnabled(value: Boolean) {
launchJob(Dispatchers.Default) {
mangaSourcesRepository.setSourceEnabled(source, value)
mangaSourcesRepository.setSourcesEnabled(setOf(source), value)
}
}

View File

@@ -70,7 +70,7 @@ class SourcesCatalogViewModel @Inject constructor(
fun addSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
val rollback = repository.setSourceEnabled(source, true)
val rollback = repository.setSourcesEnabled(setOf(source), true)
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
}
}

View File

@@ -64,7 +64,7 @@ class SourcesManageViewModel @Inject constructor(
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
launchJob(Dispatchers.Default) {
val rollback = repository.setSourceEnabled(source, isEnabled)
val rollback = repository.setSourcesEnabled(setOf(source), isEnabled)
if (!isEnabled) {
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12M8.8,14L10,12.8V4H14V12.8L15.2,14H8.8Z" />
</vector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_disable"
android:icon="@drawable/ic_eye_off"
android:title="@string/disable"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_shortcut"
android:icon="@drawable/ic_pin"
android:title="@string/create_shortcut"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_settings"
android:icon="@drawable/ic_settings"
android:title="@string/settings"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_settings"
android:title="@string/settings" />
<item
android:id="@+id/action_shortcut"
android:title="@string/create_shortcut" />
<item
android:id="@+id/action_disable"
android:title="@string/disable" />
</menu>

View File

@@ -642,4 +642,5 @@
<string name="authors">Authors</string>
<string name="blocked_by_server_message">You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.)</string>
<string name="disable">Disable</string>
<string name="sources_disabled">Sources disabled</string>
</resources>