diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 226879511..d23f8a615 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.migration.Migration +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) { val scope = processLifecycleScope if (scope.isActive) { - processLifecycleScope.launch(Dispatchers.Default) { + processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { removeObserver(observer) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt index 7fd8c8786..fc36085c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt @@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags" const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" const val TABLE_HISTORY = "history" const val TABLE_MANGA_TAGS = "manga_tags" +const val TABLE_SOURCES = "sources" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt index bd570c0c6..00243e8df 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt @@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import org.koitharu.kotatsu.core.db.TABLE_SOURCES @Entity( - tableName = "sources", + tableName = TABLE_SOURCES, ) data class MangaSourceEntity( @PrimaryKey(autoGenerate = false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index ac21c279a..578773a3c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.model +import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.toTitleCase import java.util.Locale @@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource { } return MangaSource.DUMMY } + +fun MangaSource.isNsfw() = contentType == ContentType.HENTAI diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt index c364ff800..906c42fb4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt @@ -28,7 +28,10 @@ class NewSourcesDialogFragment : private val viewModel by viewModels() - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding { + override fun onCreateViewBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ): DialogOnboardBinding { return DialogOnboardBinding.inflate(inflater, container, false) } @@ -54,6 +57,8 @@ class NewSourcesDialogFragment : override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit + override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { viewModel.onItemEnabledChanged(item, isEnabled) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index 931025253..63b8bf64e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -4,7 +4,6 @@ import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -13,7 +12,6 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogOnboardBinding import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter @@ -25,14 +23,6 @@ class OnboardDialogFragment : DialogInterface.OnClickListener, SourceLocaleListener { private val viewModel by viewModels() - private var isWelcome: Boolean = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.run { - isWelcome = getBoolean(ARG_WELCOME, false) - } - } override fun onCreateViewBinding( inflater: LayoutInflater, @@ -43,11 +33,7 @@ class OnboardDialogFragment : super.onBuildDialog(builder) .setPositiveButton(R.string.done, this) .setCancelable(false) - if (isWelcome) { - builder.setTitle(R.string.welcome) - } else { - builder.setTitle(R.string.remote_sources) - } + builder.setTitle(R.string.welcome) return builder } @@ -55,11 +41,7 @@ class OnboardDialogFragment : super.onViewBindingCreated(binding, savedInstanceState) val adapter = SourceLocalesAdapter(this) binding.recyclerView.adapter = adapter - if (isWelcome) { - binding.textViewTitle.setText(R.string.onboard_text) - } else { - binding.textViewTitle.isVisible = false - } + binding.textViewTitle.setText(R.string.onboard_text) viewModel.list.observe(viewLifecycleOwner, adapter) } @@ -76,14 +58,7 @@ class OnboardDialogFragment : companion object { private const val TAG = "OnboardDialog" - private const val ARG_WELCOME = "welcome" - fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG) - - fun showWelcome(fm: FragmentManager) { - OnboardDialogFragment().withArgs(1) { - putBoolean(ARG_WELCOME, true) - }.showAllowStateLoss(fm, TAG) - } + fun show(fm: FragmentManager) = OnboardDialogFragment().showAllowStateLoss(fm, TAG) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListProducer.kt new file mode 100644 index 000000000..82181357f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListProducer.kt @@ -0,0 +1,186 @@ +package org.koitharu.kotatsu.settings.sources + +import androidx.core.os.LocaleListCompat +import androidx.room.InvalidationTracker +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.db.TABLE_SOURCES +import org.koitharu.kotatsu.core.model.getLocaleTitle +import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.AlphanumComparator +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.core.util.ext.map +import org.koitharu.kotatsu.core.util.ext.toEnumSet +import org.koitharu.kotatsu.explore.data.MangaSourcesRepository +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import java.util.Locale +import java.util.TreeMap +import javax.inject.Inject + +@ViewModelScoped +class SourcesListProducer @Inject constructor( + lifecycle: ViewModelLifecycle, + private val repository: MangaSourcesRepository, + private val settings: AppSettings, +) : InvalidationTracker.Observer(TABLE_SOURCES) { + + private val scope = lifecycle.lifecycleScope + private var query: String = "" + private val expanded = HashSet() + val list = MutableStateFlow(emptyList()) + + private var job = scope.launch(Dispatchers.Default) { + list.value = buildList() + } + + init { + settings.observe() + .filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW } + .flowOn(Dispatchers.Default) + .onEach { onInvalidated(emptySet()) } + .launchIn(scope) + } + + override fun onInvalidated(tables: Set) { + val prevJob = job + job = scope.launch(Dispatchers.Default) { + prevJob.cancelAndJoin() + list.update { buildList() } + } + } + + fun setQuery(value: String) { + this.query = value + onInvalidated(emptySet()) + } + + fun expandCollapse(group: String?) { + if (!expanded.remove(group)) { + expanded.add(group) + } + onInvalidated(emptySet()) + } + + private suspend fun buildList(): List { + val allSources = repository.allMangaSources + val enabledSources = repository.getEnabledSources() + val isNsfwDisabled = settings.isNsfwContentDisabled + val withTip = settings.isTipEnabled(TIP_REORDER) + val enabledSet = enabledSources.toEnumSet() + if (query.isNotEmpty()) { + return allSources.mapNotNull { + if (!it.title.contains(query, ignoreCase = true)) { + return@mapNotNull null + } + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = it in enabledSet, + isDraggable = false, + isAvailable = !isNsfwDisabled || !it.isNsfw(), + ) + }.ifEmpty { + listOf(SourceConfigItem.EmptySearchResult) + } + } + val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) { + if (it in enabledSet) { + KEY_ENABLED + } else { + it.locale + } + } + map.remove(KEY_ENABLED) + val result = ArrayList(allSources.size + map.size + 2) + if (enabledSources.isNotEmpty()) { + result += SourceConfigItem.Header(R.string.enabled_sources) + if (withTip) { + result += SourceConfigItem.Tip( + TIP_REORDER, + R.drawable.ic_tap_reorder, + R.string.sources_reorder_tip, + ) + } + enabledSources.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = true, + isDraggable = true, + isAvailable = false, + ) + } + } + if (enabledSources.size != allSources.size) { + result += SourceConfigItem.Header(R.string.available_sources) + val comparator = compareBy(AlphanumComparator()) { it.name } + for ((key, list) in map) { + list.sortWith(comparator) + val isExpanded = key in expanded + result += SourceConfigItem.LocaleGroup( + localeId = key, + title = getLocaleTitle(key), + isExpanded = isExpanded, + ) + if (isExpanded) { + list.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + summary = null, + isEnabled = false, + isDraggable = false, + isAvailable = !isNsfwDisabled || !it.isNsfw(), + ) + } + } + } + } + return result + } + + private class LocaleKeyComparator : Comparator { + + private val deviceLocales = LocaleListCompat.getAdjustedDefault() + .map { it.language } + + override fun compare(a: String?, b: String?): Int { + when { + a == b -> return 0 + a == null -> return 1 + b == null -> return -1 + } + val ai = deviceLocales.indexOf(a!!) + val bi = deviceLocales.indexOf(b!!) + return when { + ai < 0 && bi < 0 -> a.compareTo(b) + ai < 0 -> 1 + bi < 0 -> -1 + else -> ai.compareTo(bi) + } + } + } + + companion object { + + private fun getLocaleTitle(localeKey: String?): String? { + val locale = Locale(localeKey ?: return null) + return locale.getDisplayLanguage(locale).toTitleCase(locale) + } + + private const val KEY_ENABLED = "!" + const val TIP_REORDER = "src_reorder" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageFragment.kt index 374c7c1ae..2da4fc4d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageFragment.kt @@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem @@ -56,7 +55,10 @@ class SourcesManageFragment : container: ViewGroup?, ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?) { + override fun onViewBindingCreated( + binding: FragmentSettingsSourcesBinding, + savedInstanceState: Bundle?, + ) { super.onViewBindingCreated(binding, savedInstanceState) val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) with(binding.recyclerView) { @@ -67,7 +69,10 @@ class SourcesManageFragment : } } viewModel.content.observe(viewLifecycleOwner, sourcesAdapter) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onActionDone.observeEvent( + viewLifecycleOwner, + ReversibleActionObserver(binding.recyclerView) + ) addMenuProvider(SourcesMenuProvider()) } @@ -94,6 +99,10 @@ class SourcesManageFragment : (activity as? SettingsActivity)?.openFragment(fragment, false) } + override fun onItemLiftClick(item: SourceConfigItem.SourceItem) { + viewModel.bringToTop(item.source) + } + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { viewModel.setEnabled(item.source, isEnabled) } @@ -127,11 +136,6 @@ class SourcesManageFragment : true } - R.id.action_locales -> { - OnboardDialogFragment.show(childFragmentManager) - true - } - R.id.action_no_nsfw -> { settings.isNsfwContentDisabled = !menuItem.isChecked true @@ -181,7 +185,7 @@ class SourcesManageFragment : target: RecyclerView.ViewHolder, toPos: Int, x: Int, - y: Int + y: Int, ) { super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) viewModel.reorderSources(fromPos, toPos) @@ -196,7 +200,10 @@ class SourcesManageFragment : target.bindingAdapterPosition, ) - override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + override fun getDragDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int { val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java) return if (item != null && item.isDraggable) { super.getDragDirs(recyclerView, viewHolder) @@ -205,7 +212,10 @@ class SourcesManageFragment : } } - override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int { val item = viewHolder.getItem(SourceConfigItem.Tip::class.java) return if (item != null) { super.getSwipeDirs(recyclerView, viewHolder) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageViewModel.kt index 1101168b3..58a9f6eea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesManageViewModel.kt @@ -1,88 +1,59 @@ package org.koitharu.kotatsu.settings.sources -import androidx.annotation.CheckResult -import androidx.core.os.LocaleListCompat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.yield import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.getLocaleTitle +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.map -import org.koitharu.kotatsu.core.util.ext.toEnumSet import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.move -import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -import java.util.Locale -import java.util.TreeMap import javax.inject.Inject -private const val KEY_ENABLED = "!" -private const val TIP_REORDER = "src_reorder" - @HiltViewModel class SourcesManageViewModel @Inject constructor( + private val database: MangaDatabase, private val settings: AppSettings, private val repository: MangaSourcesRepository, + private val listProducer: SourcesListProducer, ) : BaseViewModel() { - private val expandedGroups = MutableStateFlow(emptySet()) - private var searchQuery = MutableStateFlow(null) - private var reorderJob: Job? = null - val content = MutableStateFlow>(emptyList()) + val content = listProducer.list val onActionDone = MutableEventFlow() + private var commitJob: Job? = null init { launchJob(Dispatchers.Default) { - combine( - repository.observeEnabledSources(), - expandedGroups, - searchQuery, - observeTip(), - settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled }, - ) { sources, groups, query, tip, noNsfw -> - buildList(sources, groups, query, tip, noNsfw) - }.collectLatest { - reorderJob?.join() - content.value = it - } + database.invalidationTracker.addObserver(listProducer) } } + override fun onCleared() { + super.onCleared() + database.invalidationTracker.removeObserverAsync(listProducer) + } + fun reorderSources(oldPos: Int, newPos: Int) { val snapshot = content.value.toMutableList() - val prevJob = reorderJob - reorderJob = launchJob(Dispatchers.Default) { - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return@launchJob - } - if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return@launchJob - } - snapshot.move(oldPos, newPos) - content.value = snapshot - prevJob?.join() - val newSourcesList = snapshot.mapNotNull { x -> - if (x is SourceConfigItem.SourceItem && x.isDraggable) { - x.source - } else { - null - } - } - repository.setPositions(newSourcesList) + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return } + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return + } + snapshot.move(oldPos, newPos) + content.value = snapshot + commit(snapshot) } fun canReorder(oldPos: Int, newPos: Int): Boolean { @@ -100,6 +71,32 @@ class SourcesManageViewModel @Inject constructor( } } + fun bringToTop(source: MangaSource) { + var oldPos = -1 + var newPos = -1 + val snapshot = content.value + for ((i, x) in snapshot.withIndex()) { + if (x !is SourceConfigItem.SourceItem) { + continue + } + if (newPos == -1) { + newPos = i + } + if (x.source == source) { + oldPos = i + break + } + } + @Suppress("KotlinConstantConditions") + if (oldPos != -1 && newPos != -1) { + reorderSources(oldPos, newPos) + val revert = ReversibleAction(R.string.moved_to_top) { + reorderSources(newPos, oldPos) + } + onActionDone.call(revert) + } + } + fun disableAll() { launchJob(Dispatchers.Default) { repository.disableAllSources() @@ -107,16 +104,11 @@ class SourcesManageViewModel @Inject constructor( } fun expandOrCollapse(headerId: String?) { - val expanded = expandedGroups.value - expandedGroups.value = if (headerId in expanded) { - expanded - headerId - } else { - expanded + headerId - } + listProducer.expandCollapse(headerId) } fun performSearch(query: String?) { - searchQuery.value = query?.trim() + listProducer.setQuery(query?.trim().orEmpty()) } fun onTipClosed(item: SourceConfigItem.Tip) { @@ -125,113 +117,20 @@ class SourcesManageViewModel @Inject constructor( } } - @CheckResult - private fun buildList( - enabledSources: List, - expanded: Set, - query: String?, - withTip: Boolean, - isNsfwDisabled: Boolean, - ): List { - val allSources = repository.allMangaSources - val enabledSet = enabledSources.toEnumSet() - if (!query.isNullOrEmpty()) { - return allSources.mapNotNull { - if (!it.title.contains(query, ignoreCase = true)) { - return@mapNotNull null - } - SourceConfigItem.SourceItem( - source = it, - summary = it.getLocaleTitle(), - isEnabled = it in enabledSet, - isDraggable = false, - isAvailable = !isNsfwDisabled || !it.isNsfw(), - ) - }.ifEmpty { - listOf(SourceConfigItem.EmptySearchResult) - } - } - val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) { - if (it in enabledSet) { - KEY_ENABLED - } else { - it.locale - } - } - map.remove(KEY_ENABLED) - val result = ArrayList(allSources.size + map.size + 2) - if (enabledSources.isNotEmpty()) { - result += SourceConfigItem.Header(R.string.enabled_sources) - if (withTip) { - result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip) - } - enabledSources.mapTo(result) { - SourceConfigItem.SourceItem( - source = it, - summary = it.getLocaleTitle(), - isEnabled = true, - isDraggable = true, - isAvailable = false, - ) - } - } - if (enabledSources.size != allSources.size) { - result += SourceConfigItem.Header(R.string.available_sources) - val comparator = compareBy(AlphanumComparator()) { it.name } - for ((key, list) in map) { - list.sortWith(comparator) - val isExpanded = key in expanded - result += SourceConfigItem.LocaleGroup( - localeId = key, - title = getLocaleTitle(key), - isExpanded = isExpanded, - ) - if (isExpanded) { - list.mapTo(result) { - SourceConfigItem.SourceItem( - source = it, - summary = null, - isEnabled = false, - isDraggable = false, - isAvailable = !isNsfwDisabled || !it.isNsfw(), - ) - } + private fun commit(snapshot: List) { + val prevJob = commitJob + commitJob = launchJob { + prevJob?.cancelAndJoin() + delay(500) + val newSourcesList = snapshot.mapNotNull { x -> + if (x is SourceConfigItem.SourceItem && x.isDraggable) { + x.source + } else { + null } } - } - return result - } - - private fun getLocaleTitle(localeKey: String?): String? { - val locale = Locale(localeKey ?: return null) - return locale.getDisplayLanguage(locale).toTitleCase(locale) - } - - private fun observeTip() = settings.observeAsFlow(AppSettings.KEY_TIPS_CLOSED) { - isTipEnabled(TIP_REORDER) - } - - private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI - - private class LocaleKeyComparator : Comparator { - - private val deviceLocales = LocaleListCompat.getAdjustedDefault() - .map { it.language } - - override fun compare(a: String?, b: String?): Int { - when { - a == b -> return 0 - a == null -> return 1 - b == null -> return -1 - } - val ai = deviceLocales.indexOf(a!!) - val bi = deviceLocales.indexOf(b!!) - return when { - ai < 0 && bi < 0 -> a.compareTo(b) - ai < 0 -> 1 - bi < 0 -> -1 - else -> ai.compareTo(bi) - } + repository.setPositions(newSourcesList) + yield() } } } 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 f85dad382..5682876df 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 @@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan import android.text.style.SuperscriptSpan import android.view.View +import androidx.appcompat.widget.PopupMenu import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.core.view.isGone @@ -34,7 +35,13 @@ import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }, + { layoutInflater, parent -> + ItemFilterHeaderBinding.inflate( + layoutInflater, + parent, + false + ) + }, ) { bind { @@ -44,104 +51,121 @@ fun sourceConfigHeaderDelegate() = fun sourceConfigGroupDelegate( listener: SourceConfigListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) }, -) { +) = + adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) }, + ) { - binding.root.setOnClickListener { - listener.onHeaderClick(item) - } + binding.root.setOnClickListener { + listener.onHeaderClick(item) + } - bind { - binding.root.text = item.title ?: getString(R.string.various_languages) - binding.root.isChecked = item.isExpanded + bind { + binding.root.text = item.title ?: getString(R.string.various_languages) + binding.root.isChecked = item.isExpanded + } } -} fun sourceConfigItemCheckableDelegate( listener: SourceConfigListener, coil: ImageLoader, lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) }, -) { +) = + adapterDelegateViewBinding( + { layoutInflater, parent -> + ItemSourceConfigCheckableBinding.inflate( + layoutInflater, + parent, + false + ) + }, + ) { - binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> - listener.onItemEnabledChanged(item, isChecked) - } + binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> + listener.onItemEnabledChanged(item, isChecked) + } - bind { - binding.textViewTitle.text = if (item.isNsfw) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) + bind { + binding.textViewTitle.text = if (item.isNsfw) { + buildSpannedString { + append(item.source.title) + append(' ') + appendNsfwLabel(context) + } + } else { + item.source.title + } + binding.switchToggle.isChecked = item.isEnabled + binding.switchToggle.isEnabled = item.isAvailable + binding.textViewDescription.textAndVisible = item.summary + 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) } - } else { - item.source.title - } - binding.switchToggle.isChecked = item.isEnabled - binding.switchToggle.isEnabled = item.isAvailable - binding.textViewDescription.textAndVisible = item.summary - 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, lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, -) { +) = + adapterDelegateViewBinding( + { layoutInflater, parent -> + ItemSourceConfigBinding.inflate( + layoutInflater, + parent, + false + ) + }, + ) { - val eventListener = View.OnClickListener { v -> - when (v.id) { - R.id.imageView_add -> listener.onItemEnabledChanged(item, true) - R.id.imageView_remove -> listener.onItemEnabledChanged(item, false) - R.id.imageView_config -> listener.onItemSettingsClick(item) - } - } - binding.imageViewRemove.setOnClickListener(eventListener) - binding.imageViewAdd.setOnClickListener(eventListener) - binding.imageViewConfig.setOnClickListener(eventListener) - - bind { - binding.textViewTitle.text = if (item.isNsfw) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) + val eventListener = View.OnClickListener { v -> + when (v.id) { + R.id.imageView_add -> listener.onItemEnabledChanged(item, true) + R.id.imageView_remove -> listener.onItemEnabledChanged(item, false) + R.id.imageView_menu -> showSourceMenu(v, item, listener) } - } else { - item.source.title } - binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable - binding.imageViewRemove.isVisible = item.isEnabled - binding.imageViewConfig.isVisible = item.isEnabled - binding.textViewDescription.textAndVisible = item.summary - 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) + binding.imageViewRemove.setOnClickListener(eventListener) + binding.imageViewAdd.setOnClickListener(eventListener) + binding.imageViewMenu.setOnClickListener(eventListener) + + bind { + binding.textViewTitle.text = if (item.isNsfw) { + buildSpannedString { + append(item.source.title) + append(' ') + appendNsfwLabel(context) + } + } else { + item.source.title + } + binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable + binding.imageViewRemove.isVisible = item.isEnabled + binding.imageViewMenu.isVisible = item.isEnabled + binding.textViewDescription.textAndVisible = item.summary + 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 sourceConfigTipDelegate( - listener: OnTipCloseListener + listener: OnTipCloseListener, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) }, ) { @@ -156,14 +180,37 @@ fun sourceConfigTipDelegate( } } -fun sourceConfigEmptySearchDelegate() = adapterDelegate( - R.layout.item_sources_empty, -) { } +fun sourceConfigEmptySearchDelegate() = + adapterDelegate( + R.layout.item_sources_empty, + ) { } fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( - ForegroundColorSpan(context.getThemeColor(com.google.android.material.R.attr.colorError, Color.RED)), + ForegroundColorSpan( + context.getThemeColor( + com.google.android.material.R.attr.colorError, + Color.RED + ) + ), RelativeSizeSpan(0.74f), SuperscriptSpan(), ) { append(context.getString(R.string.nsfw)) } + +private fun showSourceMenu( + anchor: View, + item: SourceConfigItem.SourceItem, + listener: SourceConfigListener, +) { + val menu = PopupMenu(anchor.context, anchor) + menu.inflate(R.menu.popup_source_config) + menu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_settings -> listener.onItemSettingsClick(item) + R.id.action_lift -> listener.onItemLiftClick(item) + } + true + } + menu.show() +} 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 d90969f17..1ad142653 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 @@ -7,6 +7,8 @@ interface SourceConfigListener : OnTipCloseListener { fun onItemSettingsClick(item: SourceConfigItem.SourceItem) + fun onItemLiftClick(item: SourceConfigItem.SourceItem) + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) fun onHeaderClick(header: SourceConfigItem.LocaleGroup) 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 d1feb36db..b162c15ef 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 @@ -72,11 +72,7 @@ sealed interface SourceConfigItem : ListModel { } } - object EmptySearchResult : SourceConfigItem { - - override fun equals(other: Any?): Boolean { - return other === EmptySearchResult - } + data object EmptySearchResult : SourceConfigItem { override fun areItemsTheSame(other: ListModel): Boolean { return other is EmptySearchResult diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml index 599ffbfcd..ec88c7b5e 100644 --- a/app/src/main/res/layout/item_source_config.xml +++ b/app/src/main/res/layout/item_source_config.xml @@ -52,14 +52,15 @@ + android:src="@drawable/abc_ic_menu_overflow_material" + app:tint="?colorControlNormal" /> - - + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd893ee00..f08133864 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,4 +483,6 @@ Directories Main screen sections No more items can be added + To top + Moved to top