Option to move manga source to top

This commit is contained in:
Koitharu
2023-09-07 13:27:13 +03:00
parent 95547a8d03
commit c4ff37350c
16 changed files with 434 additions and 297 deletions

View File

@@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) { fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope val scope = processLifecycleScope
if (scope.isActive) { if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer) removeObserver(observer)
} }
} }

View File

@@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history" const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity( @Entity(
tableName = "sources", tableName = TABLE_SOURCES,
) )
data class MangaSourceEntity( data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model 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.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
@@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource {
} }
return MangaSource.DUMMY return MangaSource.DUMMY
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

View File

@@ -28,7 +28,10 @@ class NewSourcesDialogFragment :
private val viewModel by viewModels<NewSourcesViewModel>() 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) return DialogOnboardBinding.inflate(inflater, container, false)
} }
@@ -54,6 +57,8 @@ class NewSourcesDialogFragment :
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.onItemEnabledChanged(item, isEnabled) viewModel.onItemEnabledChanged(item, isEnabled)
} }

View File

@@ -4,7 +4,6 @@ import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss 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.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
@@ -25,14 +23,6 @@ class OnboardDialogFragment :
DialogInterface.OnClickListener, SourceLocaleListener { DialogInterface.OnClickListener, SourceLocaleListener {
private val viewModel by viewModels<OnboardViewModel>() 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( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -43,11 +33,7 @@ class OnboardDialogFragment :
super.onBuildDialog(builder) super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(false) .setCancelable(false)
if (isWelcome) { builder.setTitle(R.string.welcome)
builder.setTitle(R.string.welcome)
} else {
builder.setTitle(R.string.remote_sources)
}
return builder return builder
} }
@@ -55,11 +41,7 @@ class OnboardDialogFragment :
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
val adapter = SourceLocalesAdapter(this) val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
if (isWelcome) { binding.textViewTitle.setText(R.string.onboard_text)
binding.textViewTitle.setText(R.string.onboard_text)
} else {
binding.textViewTitle.isVisible = false
}
viewModel.list.observe(viewLifecycleOwner, adapter) viewModel.list.observe(viewLifecycleOwner, adapter)
} }
@@ -76,14 +58,7 @@ class OnboardDialogFragment :
companion object { companion object {
private const val TAG = "OnboardDialog" private const val TAG = "OnboardDialog"
private const val ARG_WELCOME = "welcome"
fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG) fun show(fm: FragmentManager) = OnboardDialogFragment().showAllowStateLoss(fm, TAG)
fun showWelcome(fm: FragmentManager) {
OnboardDialogFragment().withArgs(1) {
putBoolean(ARG_WELCOME, true)
}.showAllowStateLoss(fm, TAG)
}
} }
} }

View File

@@ -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"
}
}

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity 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.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -56,7 +55,10 @@ class SourcesManageFragment :
container: ViewGroup?, container: ViewGroup?,
) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(
binding: FragmentSettingsSourcesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
with(binding.recyclerView) { with(binding.recyclerView) {
@@ -67,7 +69,10 @@ class SourcesManageFragment :
} }
} }
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter) viewModel.content.observe(viewLifecycleOwner, sourcesAdapter)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observeEvent(
viewLifecycleOwner,
ReversibleActionObserver(binding.recyclerView)
)
addMenuProvider(SourcesMenuProvider()) addMenuProvider(SourcesMenuProvider())
} }
@@ -94,6 +99,10 @@ class SourcesManageFragment :
(activity as? SettingsActivity)?.openFragment(fragment, false) (activity as? SettingsActivity)?.openFragment(fragment, false)
} }
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) {
viewModel.bringToTop(item.source)
}
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled) viewModel.setEnabled(item.source, isEnabled)
} }
@@ -127,11 +136,6 @@ class SourcesManageFragment :
true true
} }
R.id.action_locales -> {
OnboardDialogFragment.show(childFragmentManager)
true
}
R.id.action_no_nsfw -> { R.id.action_no_nsfw -> {
settings.isNsfwContentDisabled = !menuItem.isChecked settings.isNsfwContentDisabled = !menuItem.isChecked
true true
@@ -181,7 +185,7 @@ class SourcesManageFragment :
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
toPos: Int, toPos: Int,
x: Int, x: Int,
y: Int y: Int,
) { ) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderSources(fromPos, toPos) viewModel.reorderSources(fromPos, toPos)
@@ -196,7 +200,10 @@ class SourcesManageFragment :
target.bindingAdapterPosition, 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) val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java)
return if (item != null && item.isDraggable) { return if (item != null && item.isDraggable) {
super.getDragDirs(recyclerView, viewHolder) 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) val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
return if (item != null) { return if (item != null) {
super.getSwipeDirs(recyclerView, viewHolder) super.getSwipeDirs(recyclerView, viewHolder)

View File

@@ -1,88 +1,59 @@
package org.koitharu.kotatsu.settings.sources package org.koitharu.kotatsu.settings.sources
import androidx.annotation.CheckResult
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R 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.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction 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.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call 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.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject import javax.inject.Inject
private const val KEY_ENABLED = "!"
private const val TIP_REORDER = "src_reorder"
@HiltViewModel @HiltViewModel
class SourcesManageViewModel @Inject constructor( class SourcesManageViewModel @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings, private val settings: AppSettings,
private val repository: MangaSourcesRepository, private val repository: MangaSourcesRepository,
private val listProducer: SourcesListProducer,
) : BaseViewModel() { ) : BaseViewModel() {
private val expandedGroups = MutableStateFlow(emptySet<String?>()) val content = listProducer.list
private var searchQuery = MutableStateFlow<String?>(null)
private var reorderJob: Job? = null
val content = MutableStateFlow<List<SourceConfigItem>>(emptyList())
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
private var commitJob: Job? = null
init { init {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
combine( database.invalidationTracker.addObserver(listProducer)
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
}
} }
} }
override fun onCleared() {
super.onCleared()
database.invalidationTracker.removeObserverAsync(listProducer)
}
fun reorderSources(oldPos: Int, newPos: Int) { fun reorderSources(oldPos: Int, newPos: Int) {
val snapshot = content.value.toMutableList() val snapshot = content.value.toMutableList()
val prevJob = reorderJob if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
reorderJob = launchJob(Dispatchers.Default) { return
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[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
} }
fun canReorder(oldPos: Int, newPos: Int): Boolean { 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() { fun disableAll() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
repository.disableAllSources() repository.disableAllSources()
@@ -107,16 +104,11 @@ class SourcesManageViewModel @Inject constructor(
} }
fun expandOrCollapse(headerId: String?) { fun expandOrCollapse(headerId: String?) {
val expanded = expandedGroups.value listProducer.expandCollapse(headerId)
expandedGroups.value = if (headerId in expanded) {
expanded - headerId
} else {
expanded + headerId
}
} }
fun performSearch(query: String?) { fun performSearch(query: String?) {
searchQuery.value = query?.trim() listProducer.setQuery(query?.trim().orEmpty())
} }
fun onTipClosed(item: SourceConfigItem.Tip) { fun onTipClosed(item: SourceConfigItem.Tip) {
@@ -125,113 +117,20 @@ class SourcesManageViewModel @Inject constructor(
} }
} }
@CheckResult private fun commit(snapshot: List<SourceConfigItem>) {
private fun buildList( val prevJob = commitJob
enabledSources: List<MangaSource>, commitJob = launchJob {
expanded: Set<String?>, prevJob?.cancelAndJoin()
query: String?, delay(500)
withTip: Boolean, val newSourcesList = snapshot.mapNotNull { x ->
isNsfwDisabled: Boolean, if (x is SourceConfigItem.SourceItem && x.isDraggable) {
): List<SourceConfigItem> { x.source
val allSources = repository.allMangaSources } else {
val enabledSet = enabledSources.toEnumSet() null
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(),
)
}
} }
} }
} repository.setPositions(newSourcesList)
return result yield()
}
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)
}
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import android.view.View import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.isGone import androidx.core.view.isGone
@@ -34,7 +35,13 @@ import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
fun sourceConfigHeaderDelegate() = fun sourceConfigHeaderDelegate() =
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>( adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent ->
ItemFilterHeaderBinding.inflate(
layoutInflater,
parent,
false
)
},
) { ) {
bind { bind {
@@ -44,104 +51,121 @@ fun sourceConfigHeaderDelegate() =
fun sourceConfigGroupDelegate( fun sourceConfigGroupDelegate(
listener: SourceConfigListener, 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 { binding.root.setOnClickListener {
listener.onHeaderClick(item) listener.onHeaderClick(item)
} }
bind { bind {
binding.root.text = item.title ?: getString(R.string.various_languages) binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded binding.root.isChecked = item.isExpanded
}
} }
}
fun sourceConfigItemCheckableDelegate( fun sourceConfigItemCheckableDelegate(
listener: SourceConfigListener, listener: SourceConfigListener,
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, 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 -> binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked) listener.onItemEnabledChanged(item, isChecked)
} }
bind { bind {
binding.textViewTitle.text = if (item.isNsfw) { binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString { buildSpannedString {
append(item.source.title) append(item.source.title)
append(' ') append(' ')
appendNsfwLabel(context) 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( fun sourceConfigItemDelegate2(
listener: SourceConfigListener, listener: SourceConfigListener,
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, 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 -> val eventListener = View.OnClickListener { v ->
when (v.id) { when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true) R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false) R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_config -> listener.onItemSettingsClick(item) R.id.imageView_menu -> showSourceMenu(v, item, listener)
}
}
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)
} }
} else {
item.source.title
} }
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewRemove.isVisible = item.isEnabled binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewConfig.isVisible = item.isEnabled binding.imageViewMenu.setOnClickListener(eventListener)
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) bind {
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.textViewTitle.text = if (item.isNsfw) {
crossfade(context) buildSpannedString {
error(fallbackIcon) append(item.source.title)
placeholder(fallbackIcon) append(' ')
fallback(fallbackIcon) appendNsfwLabel(context)
source(item.source) }
enqueueWith(coil) } 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( fun sourceConfigTipDelegate(
listener: OnTipCloseListener<SourceConfigItem.Tip> listener: OnTipCloseListener<SourceConfigItem.Tip>,
) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>( ) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>(
{ layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
) { ) {
@@ -156,14 +180,37 @@ fun sourceConfigTipDelegate(
} }
} }
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>( fun sourceConfigEmptySearchDelegate() =
R.layout.item_sources_empty, adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
) { } R.layout.item_sources_empty,
) { }
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( 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), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),
) { ) {
append(context.getString(R.string.nsfw)) 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()
}

View File

@@ -7,6 +7,8 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem) fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemLiftClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup) fun onHeaderClick(header: SourceConfigItem.LocaleGroup)

View File

@@ -72,11 +72,7 @@ sealed interface SourceConfigItem : ListModel {
} }
} }
object EmptySearchResult : SourceConfigItem { data object EmptySearchResult : SourceConfigItem {
override fun equals(other: Any?): Boolean {
return other === EmptySearchResult
}
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is EmptySearchResult return other is EmptySearchResult

View File

@@ -52,14 +52,15 @@
</LinearLayout> </LinearLayout>
<ImageView <ImageView
android:id="@+id/imageView_config" android:id="@+id/imageView_menu"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless" android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings" android:contentDescription="@string/more"
android:padding="@dimen/margin_small" android:padding="@dimen/margin_small"
android:scaleType="center" android:scaleType="center"
android:src="@drawable/ic_settings" /> android:src="@drawable/abc_ic_menu_overflow_material"
app:tint="?colorControlNormal" />
<ImageView <ImageView
android:id="@+id/imageView_add" android:id="@+id/imageView_add"

View File

@@ -16,11 +16,6 @@
android:title="@string/disable_nsfw" android:title="@string/disable_nsfw"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_locales"
android:title="@string/languages"
app:showAsAction="never" />
<item <item
android:id="@+id/action_disable_all" android:id="@+id/action_disable_all"
android:title="@string/disable_all" android:title="@string/disable_all"

View 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>

View File

@@ -483,4 +483,6 @@
<string name="directories">Directories</string> <string name="directories">Directories</string>
<string name="main_screen_sections">Main screen sections</string> <string name="main_screen_sections">Main screen sections</string>
<string name="items_limit_exceeded">No more items can be added</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> </resources>