From 9b3ce4d8499b83631798825c58a39f5491d1e5bf Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 7 Jul 2024 11:15:44 +0300 Subject: [PATCH] Ability to pin manga sources (close #830, close #531) --- .../kotatsu/core/db/dao/MangaSourcesDao.kt | 9 ++-- .../explore/data/MangaSourcesRepository.kt | 33 ++++++++++++-- .../kotatsu/explore/ui/ExploreFragment.kt | 10 +++++ .../kotatsu/explore/ui/ExploreViewModel.kt | 12 ++++++ .../adapter/SourceConfigAdapterDelegates.kt | 43 +++---------------- .../sources/adapter/SourceConfigListener.kt | 2 + .../sources/manage/SourcesListProducer.kt | 3 ++ .../sources/manage/SourcesManageFragment.kt | 4 ++ .../sources/manage/SourcesManageViewModel.kt | 13 +++++- .../sources/model/SourceConfigItem.kt | 1 + app/src/main/res/drawable/ic_pin_small.xml | 12 ++++++ app/src/main/res/drawable/ic_settings.xml | 2 +- app/src/main/res/drawable/ic_shortcut.xml | 12 ++++++ app/src/main/res/drawable/ic_unpin.xml | 12 ++++++ .../main/res/layout/item_source_config.xml | 2 + app/src/main/res/menu/mode_source.xml | 14 +++++- app/src/main/res/menu/popup_source_config.xml | 5 +++ app/src/main/res/values/strings.xml | 6 +++ 18 files changed, 149 insertions(+), 46 deletions(-) create mode 100644 app/src/main/res/drawable/ic_pin_small.xml create mode 100644 app/src/main/res/drawable/ic_shortcut.xml create mode 100644 app/src/main/res/drawable/ic_unpin.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt index b39df0ab6..fcef09da8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -18,7 +18,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder @Dao abstract class MangaSourcesDao { - @Query("SELECT * FROM sources ORDER BY sort_key") + @Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key") abstract suspend fun findAll(): List @Query("SELECT source FROM sources WHERE enabled = 1") @@ -27,7 +27,7 @@ abstract class MangaSourcesDao { @Query("SELECT * FROM sources WHERE added_in >= :version") abstract suspend fun findAllFromVersion(version: Int): List - @Query("SELECT * FROM sources ORDER BY sort_key") + @Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key") abstract fun observeAll(): Flow> @Query("SELECT enabled FROM sources WHERE source = :source") @@ -55,6 +55,9 @@ abstract class MangaSourcesDao { @Upsert abstract suspend fun upsert(entry: MangaSourceEntity) + @Query("SELECT * FROM sources WHERE pinned = 1") + abstract suspend fun findAllPinned(): List + fun observeEnabled(order: SourcesSortOrder): Flow> { val orderBy = getOrderBy(order) @@ -67,7 +70,7 @@ abstract class MangaSourcesDao { val orderBy = getOrderBy(order) @Language("RoomSql") - val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy") + val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy") return findAllImpl(query) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 48e1ffff8..c3e4b7449 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -51,6 +51,14 @@ class MangaSourcesRepository @Inject constructor( return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order) } + suspend fun getPinnedSources(): Set { + assimilateNewSources() + val skipNsfw = settings.isNsfwContentDisabled + return dao.findAllPinned().mapNotNullTo(EnumSet.noneOf(MangaSource::class.java)) { + it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() } + } + } + suspend fun getDisabledSources(): Set { assimilateNewSources() val result = EnumSet.copyOf(remoteSources) @@ -226,8 +234,11 @@ class MangaSourcesRepository @Inject constructor( return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty() } - suspend fun setIsPinned(source: MangaSource, isPinned: Boolean) { - dao.setPinned(source.name, isPinned) + suspend fun setIsPinned(sources: Collection, isPinned: Boolean): ReversibleHandle { + setSourcesPinnedImpl(sources, isPinned) + return ReversibleHandle { + setSourcesEnabledImpl(sources, !isPinned) + } } suspend fun trackUsage(source: MangaSource) { @@ -246,6 +257,18 @@ class MangaSourcesRepository @Inject constructor( } } + private suspend fun setSourcesPinnedImpl(sources: Collection, isPinned: Boolean) { + if (sources.size == 1) { // fast path + dao.setPinned(sources.first().name, isPinned) + return + } + db.withTransaction { + for (source in sources) { + dao.setPinned(source.name, isPinned) + } + } + } + private suspend fun getNewSources(): MutableSet { val entities = dao.findAll() val result = EnumSet.copyOf(remoteSources) @@ -260,6 +283,7 @@ class MangaSourcesRepository @Inject constructor( sortOrder: SourcesSortOrder?, ): MutableList { val result = ArrayList(size) + val pinned = EnumSet.noneOf(MangaSource::class.java) for (entity in this) { val source = entity.source.toMangaSourceOrNull() ?: continue if (skipNsfwSources && source.isNsfw()) { @@ -267,10 +291,13 @@ class MangaSourcesRepository @Inject constructor( } if (source in remoteSources) { result.add(source) + if (entity.isPinned) { + pinned.add(source) + } } } if (sortOrder == SourcesSortOrder.ALPHABETIC) { - result.sortBy { it.title } + result.sortWith(compareBy { it in pinned }.thenBy { it.title }) } return result } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index a9c34b87b..fc334dde9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -196,6 +196,16 @@ class ExploreFragment : mode.finish() } + R.id.action_pin -> { + viewModel.setSourcesPinned(selectedSources, isPinned = true) + mode.finish() + } + + R.id.action_unpin -> { + viewModel.setSourcesPinned(selectedSources, isPinned = false) + mode.finish() + } + else -> return false } return true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 30eba8c14..fad516b34 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -108,6 +108,18 @@ class ExploreViewModel @Inject constructor( } } + fun setSourcesPinned(sources: Set, isPinned: Boolean) { + launchJob(Dispatchers.Default) { + sourcesRepository.setIsPinned(sources, isPinned) + val message = if (sources.size == 1) { + if (isPinned) R.string.source_pinned else R.string.source_unpinned + } else { + if (isPinned) R.string.sources_pinned else R.string.sources_unpinned + } + onActionDone.call(ReversibleAction(message, null)) + } + } + fun respondSuggestionTip(isAccepted: Boolean) { settings.isSuggestionsEnabled = isAccepted settings.closeTip(TIP_SUGGESTIONS) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index e8c41887d..3470c28de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings.sources.adapter import android.view.View import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.isGone import androidx.core.view.isVisible @@ -16,49 +17,14 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener import org.koitharu.kotatsu.core.util.ext.crossfade +import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding -import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding import org.koitharu.kotatsu.databinding.ItemTipBinding import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -fun sourceConfigItemCheckableDelegate( - listener: SourceConfigListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemSourceConfigCheckableBinding.inflate( - layoutInflater, - parent, - false, - ) - }, -) { - - binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> - listener.onItemEnabledChanged(item, isChecked) - } - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - binding.switchToggle.isChecked = item.isEnabled - binding.switchToggle.isEnabled = item.isAvailable - binding.textViewDescription.text = item.source.getSummary(context) - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - crossfade(context) - error(fallbackIcon) - placeholder(fallbackIcon) - fallback(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} - fun sourceConfigItemDelegate2( listener: SourceConfigListener, coil: ImageLoader, @@ -73,6 +39,7 @@ fun sourceConfigItemDelegate2( }, ) { + val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) val eventListener = View.OnClickListener { v -> when (v.id) { R.id.imageView_add -> listener.onItemEnabledChanged(item, true) @@ -89,6 +56,7 @@ fun sourceConfigItemDelegate2( binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable binding.imageViewRemove.isVisible = item.isEnabled binding.imageViewMenu.isVisible = item.isEnabled + binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null binding.textViewDescription.text = item.source.getSummary(context) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { @@ -132,12 +100,15 @@ private fun showSourceMenu( menu.inflate(R.menu.popup_source_config) menu.menu.findItem(R.id.action_shortcut) ?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context) + menu.menu.findItem(R.id.action_pin)?.isVisible = item.isEnabled + menu.menu.findItem(R.id.action_pin)?.isChecked = item.isPinned menu.menu.findItem(R.id.action_lift)?.isVisible = item.isDraggable menu.setOnMenuItemClickListener { when (it.itemId) { R.id.action_settings -> listener.onItemSettingsClick(item) R.id.action_lift -> listener.onItemLiftClick(item) R.id.action_shortcut -> listener.onItemShortcutClick(item) + R.id.action_pin -> listener.onItemPinClick(item) } true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt index 2a09d6516..82fa5212f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt @@ -11,5 +11,7 @@ interface SourceConfigListener : OnTipCloseListener { fun onItemShortcutClick(item: SourceConfigItem.SourceItem) + fun onItemPinClick(item: SourceConfigItem.SourceItem) + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt index c519807b4..f088acd2a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt @@ -61,6 +61,7 @@ class SourcesListProducer @Inject constructor( private suspend fun buildList(): List { val enabledSources = repository.getEnabledSources() + val pinned = repository.getPinnedSources() val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) @@ -75,6 +76,7 @@ class SourcesListProducer @Inject constructor( isEnabled = it in enabledSet, isDraggable = false, isAvailable = !isNsfwDisabled || !it.isNsfw(), + isPinned = it in pinned, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -95,6 +97,7 @@ class SourcesListProducer @Inject constructor( isEnabled = true, isDraggable = isReorderAvailable, isAvailable = false, + isPinned = it in pinned, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index f5a7e051a..b970465cc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -120,6 +120,10 @@ class SourcesManageFragment : } } + override fun onItemPinClick(item: SourceConfigItem.SourceItem) { + viewModel.setPinned(item.source, !item.isPinned) + } + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { viewModel.setEnabled(item.source, isEnabled) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt index 8175142e4..d39187b00 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt @@ -58,8 +58,9 @@ class SourcesManageViewModel @Inject constructor( fun canReorder(oldPos: Int, newPos: Int): Boolean { val snapshot = content.value - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false - return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true + val oldPosItem = snapshot.getOrNull(oldPos) as? SourceConfigItem.SourceItem ?: return false + val newPosItem = snapshot.getOrNull(newPos) as? SourceConfigItem.SourceItem ?: return false + return oldPosItem.isEnabled && newPosItem.isEnabled && oldPosItem.isPinned == newPosItem.isPinned } fun setEnabled(source: MangaSource, isEnabled: Boolean) { @@ -71,6 +72,14 @@ class SourcesManageViewModel @Inject constructor( } } + fun setPinned(source: MangaSource, isPinned: Boolean) { + launchJob(Dispatchers.Default) { + val rollback = repository.setIsPinned(setOf(source), isPinned) + val message = if (isPinned) R.string.source_pinned else R.string.source_unpinned + onActionDone.call(ReversibleAction(message, rollback)) + } + } + fun bringToTop(source: MangaSource) { val snapshot = content.value launchJob(Dispatchers.Default) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt index a68b6581b..b5ef3263b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -13,6 +13,7 @@ sealed interface SourceConfigItem : ListModel { val isEnabled: Boolean, val isDraggable: Boolean, val isAvailable: Boolean, + val isPinned: Boolean, ) : SourceConfigItem { val isNsfw: Boolean diff --git a/app/src/main/res/drawable/ic_pin_small.xml b/app/src/main/res/drawable/ic_pin_small.xml new file mode 100644 index 000000000..383c74b9f --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_small.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 2eef40149..e6e61e610 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_shortcut.xml b/app/src/main/res/drawable/ic_shortcut.xml new file mode 100644 index 000000000..fe64806f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_unpin.xml b/app/src/main/res/drawable/ic_unpin.xml new file mode 100644 index 000000000..a3c89da86 --- /dev/null +++ b/app/src/main/res/drawable/ic_unpin.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml index b1c524539..4d958f260 100644 --- a/app/src/main/res/layout/item_source_config.xml +++ b/app/src/main/res/layout/item_source_config.xml @@ -35,9 +35,11 @@ android:id="@+id/textView_title" android:layout_width="match_parent" android:layout_height="wrap_content" + android:drawablePadding="4dp" android:ellipsize="end" android:singleLine="true" android:textAppearance="?attr/textAppearanceTitleSmall" + tools:drawableStart="@drawable/ic_pin_small" tools:text="@tools:sample/lorem[15]" /> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c97bb7fef..1300322d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -657,4 +657,10 @@ Preferred image server %1$s: %2$s Crop pages + Pin + Unpin + Source pinned + Source unpinned + Sources unpinned + Sources pinned