Option to move manga source to top
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,7 +28,10 @@ class NewSourcesDialogFragment :
|
||||
|
||||
private val viewModel by viewModels<NewSourcesViewModel>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<OnboardViewModel>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>()
|
||||
val list = MutableStateFlow(emptyList<SourceConfigItem>())
|
||||
|
||||
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<String>) {
|
||||
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<SourceConfigItem> {
|
||||
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<SourceConfigItem>(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<MangaSource, String>(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<String?> {
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String?>())
|
||||
private var searchQuery = MutableStateFlow<String?>(null)
|
||||
private var reorderJob: Job? = null
|
||||
val content = MutableStateFlow<List<SourceConfigItem>>(emptyList())
|
||||
val content = listProducer.list
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
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<MangaSource>,
|
||||
expanded: Set<String?>,
|
||||
query: String?,
|
||||
withTip: Boolean,
|
||||
isNsfwDisabled: Boolean,
|
||||
): List<SourceConfigItem> {
|
||||
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<SourceConfigItem>(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<MangaSource, String>(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<SourceConfigItem>) {
|
||||
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<String?> {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
|
||||
{ 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<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
|
||||
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
) =
|
||||
adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
|
||||
{ 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<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
|
||||
{ layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
) =
|
||||
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
|
||||
{ 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<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
) =
|
||||
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
|
||||
{ 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<SourceConfigItem.Tip>
|
||||
listener: OnTipCloseListener<SourceConfigItem.Tip>,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>(
|
||||
{ layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
@@ -156,14 +180,37 @@ fun sourceConfigTipDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
|
||||
R.layout.item_sources_empty,
|
||||
) { }
|
||||
fun sourceConfigEmptySearchDelegate() =
|
||||
adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
|
||||
|
||||
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemLiftClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
|
||||
|
||||
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,14 +52,15 @@
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_config"
|
||||
android:id="@+id/imageView_menu"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/settings"
|
||||
android:contentDescription="@string/more"
|
||||
android:padding="@dimen/margin_small"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_settings" />
|
||||
android:src="@drawable/abc_ic_menu_overflow_material"
|
||||
app:tint="?colorControlNormal" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_add"
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
android:title="@string/disable_nsfw"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_locales"
|
||||
android:title="@string/languages"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_disable_all"
|
||||
android:title="@string/disable_all"
|
||||
|
||||
13
app/src/main/res/menu/popup_source_config.xml
Normal file
13
app/src/main/res/menu/popup_source_config.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_lift"
|
||||
android:title="@string/to_top" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:title="@string/settings" />
|
||||
|
||||
</menu>
|
||||
@@ -483,4 +483,6 @@
|
||||
<string name="directories">Directories</string>
|
||||
<string name="main_screen_sections">Main screen sections</string>
|
||||
<string name="items_limit_exceeded">No more items can be added</string>
|
||||
<string name="to_top">To top</string>
|
||||
<string name="moved_to_top">Moved to top</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user