Просмотр исходного кода

feat: 重写书源列表界面

HapeLee 3 месяцев назад
Родитель
Сommit
b2d00e6ce0

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -221,6 +221,7 @@ import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.book.source.manage.BookSourceViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
@@ -447,6 +448,7 @@ val appModule = module {
     viewModelOf(::TagGroupRuleViewModel)
     viewModelOf(::DictViewModel)
     viewModelOf(::RssSourceViewModel)
+    viewModelOf(::BookSourceViewModel)
     viewModelOf(::RssSortViewModel)
     viewModelOf(::RssArticlesViewModel)
     viewModelOf(::ReadRssViewModel)

+ 33 - 778
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt

@@ -1,788 +1,43 @@
 package io.legado.app.ui.book.source.manage
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.SubMenu
-import android.view.WindowManager
-import androidx.activity.viewModels
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.widget.PopupMenu
-import androidx.appcompat.widget.SearchView
-import androidx.core.os.bundleOf
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LifecycleRegistry
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.ItemTouchHelper
-import com.google.android.material.snackbar.Snackbar
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
-import io.legado.app.data.AppDatabase
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.databinding.ActivityBookSourceBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.theme.primaryColor
-import io.legado.app.model.CheckSource
-import io.legado.app.model.Debug
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.association.ImportBookSourceDialog
-import io.legado.app.ui.book.search.SearchActivity
-import io.legado.app.ui.book.search.SearchScope
-import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
-import io.legado.app.ui.config.CheckSourceConfig
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.SelectActionBar
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.ACache
-import io.legado.app.utils.NetworkUtils
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
-import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
-import io.legado.app.utils.observeEvent
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setEdgeEffectColor
-import io.legado.app.utils.share
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.transaction
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 
-/**
- * 书源管理界面
- */
-class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    BookSourceAdapter.CallBack,
-    SelectActionBar.CallBack,
-    SearchView.OnQueryTextListener {
-    override val binding by viewBinding(ActivityBookSourceBinding::inflate)
-    override val viewModel by viewModels<BookSourceViewModel>()
-    private val importRecordKey = "bookSourceRecordKey"
-    private val adapter by lazy { BookSourceAdapter(this, this, binding.recyclerView) }
-    private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private var sourceFlowJob: Job? = null
-    private var checkMessageRefreshJob: Job? = null
-    private val groups = linkedSetOf<String>()
-    private var groupMenu: SubMenu? = null
-    override var sort = BookSourceSort.Default
-        private set
-    override var sortAscending = true
-        private set
-    private var snackBar: Snackbar? = null
-    private var groupSourcesByDomain = false
-    private val hostMap = hashMapOf<String, String>()
-    private val qrResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(ImportBookSourceDialog(it))
-    }
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            showDialogFragment(ImportBookSourceDialog(uri.toString()))
-        }
-    }
-    private val exportDir = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-    private val groupMenuLifecycleOwner = object : LifecycleOwner {
-        private val registry = LifecycleRegistry(this)
-        override val lifecycle: Lifecycle get() = registry
-
-        fun onMenuOpened() {
-            registry.handleLifecycleEvent(Lifecycle.Event.ON_START)
-        }
-
-        fun onMenuClosed() {
-            registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
-        }
-
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        upBookSource()
-        initLiveDataGroup()
-        initSelectActionBar()
-        resumeCheckSource()
-        if (!LocalConfig.bookSourcesHelpVersionIsLast) {
-            showHelp("SourceMBookHelp")
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_source, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        groupMenu = menu.findItem(R.id.menu_group).subMenu
-        val sortSubMenu = menu.findItem(R.id.action_sort).subMenu!!
-        sortSubMenu.findItem(R.id.menu_sort_desc).isChecked = !sortAscending
-        sortSubMenu.setGroupCheckable(R.id.menu_group_sort, true, true)
-        upGroupMenu()
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add_book_source -> startActivity<BookSourceEditActivity>()
-            R.id.menu_import_qr -> qrResult.launch()
-            R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-
-            R.id.menu_import_onLine -> showImportDialog()
-
-            R.id.menu_sort_desc -> {
-                sortAscending = !sortAscending
-                item.isChecked = !sortAscending
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_manual -> {
-                item.isChecked = true
-                sort = BookSourceSort.Default
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_auto -> {
-                item.isChecked = true
-                sort = BookSourceSort.Weight
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_name -> {
-                item.isChecked = true
-                sort = BookSourceSort.Name
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_url -> {
-                item.isChecked = true
-                sort = BookSourceSort.Url
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_time -> {
-                item.isChecked = true
-                sort = BookSourceSort.Update
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_respondTime -> {
-                item.isChecked = true
-                sort = BookSourceSort.Respond
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_enable -> {
-                item.isChecked = true
-                sort = BookSourceSort.Enable
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_enabled_group -> {
-                searchView.setQuery(getString(R.string.enabled), true)
-            }
-
-            R.id.menu_disabled_group -> {
-                searchView.setQuery(getString(R.string.disabled), true)
-            }
-
-            R.id.menu_group_login -> {
-                searchView.setQuery(getString(R.string.need_login), true)
-            }
-
-            R.id.menu_group_null -> {
-                searchView.setQuery(getString(R.string.no_group), true)
-            }
-
-            R.id.menu_enabled_explore_group -> {
-                searchView.setQuery(getString(R.string.enabled_explore), true)
-            }
-
-            R.id.menu_disabled_explore_group -> {
-                searchView.setQuery(getString(R.string.disabled_explore), true)
-            }
-
-            R.id.menu_group_sources_by_domain -> {
-                item.isChecked = !item.isChecked
-                groupSourcesByDomain = item.isChecked
-                adapter.showSourceHost = item.isChecked
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_help -> showHelp("SourceMBookHelp")
-        }
-        if (item.groupId == R.id.source_group) {
-            searchView.setQuery("group:${item.title}", true)
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initRecyclerView() {
-        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.addItemDecoration(VerticalDivider(this))
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15)
-        // When this page is opened, it is in selection mode
-        val dragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        dragSelectTouchHelper.activeSlideSelect()
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initSearchView() {
-        searchView.queryHint = getString(R.string.search_book_source)
-        searchView.setOnQueryTextListener(this)
-    }
-
-
-    private fun upBookSource(searchKey: String? = null) {
-        sourceFlowJob?.cancel()
-        sourceFlowJob = lifecycleScope.launch {
-            when {
-                searchKey.isNullOrEmpty() -> {
-                    appDb.bookSourceDao.flowAll()
-                }
-
-                searchKey == getString(R.string.enabled) -> {
-                    appDb.bookSourceDao.flowEnabled()
-                }
-
-                searchKey == getString(R.string.disabled) -> {
-                    appDb.bookSourceDao.flowDisabled()
-                }
-
-                searchKey == getString(R.string.need_login) -> {
-                    appDb.bookSourceDao.flowLogin()
-                }
-
-                searchKey == getString(R.string.no_group) -> {
-                    appDb.bookSourceDao.flowNoGroup()
-                }
-
-                searchKey == getString(R.string.enabled_explore) -> {
-                    appDb.bookSourceDao.flowEnabledExplore()
-                }
-
-                searchKey == getString(R.string.disabled_explore) -> {
-                    appDb.bookSourceDao.flowDisabledExplore()
-                }
-
-                searchKey.startsWith("group:") -> {
-                    val key = searchKey.substringAfter("group:")
-                    appDb.bookSourceDao.flowGroupSearch(key)
-                }
-
-                else -> {
-                    appDb.bookSourceDao.flowSearch(searchKey)
-                }
-            }.map { data ->
-                hostMap.clear()
-                if (groupSourcesByDomain) {
-                    data.sortedWith(
-                        compareBy<BookSourcePart> { getSourceHost(it.bookSourceUrl) == "#" }
-                            .thenBy { getSourceHost(it.bookSourceUrl) }
-                            .thenByDescending { it.lastUpdateTime })
-                } else if (sortAscending) {
-                    when (sort) {
-                        BookSourceSort.Weight -> data.sortedBy { it.weight }
-                        BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                            o1.bookSourceName.cnCompare(o2.bookSourceName)
-                        }
-
-                        BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl }
-                        BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime }
-                        BookSourceSort.Respond -> data.sortedBy { it.respondTime }
-                        BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                            var sort = -o1.enabled.compareTo(o2.enabled)
-                            if (sort == 0) {
-                                sort = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                            }
-                            sort
-                        }
-
-                        else -> data
-                    }
-                } else {
-                    when (sort) {
-                        BookSourceSort.Weight -> data.sortedByDescending { it.weight }
-                        BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                            o2.bookSourceName.cnCompare(o1.bookSourceName)
-                        }
-
-                        BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl }
-                        BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime }
-                        BookSourceSort.Respond -> data.sortedByDescending { it.respondTime }
-                        BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                            var sort = o1.enabled.compareTo(o2.enabled)
-                            if (sort == 0) {
-                                sort = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                            }
-                            sort
-                        }
-
-                        else -> data.reversed()
-                    }
-                }
-            }.flowWithLifecycleAndDatabaseChange(
-                lifecycle,
-                table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-            ).catch {
-                AppLog.put("书源界面更新书源出错", it)
-            }.flowOn(IO).conflate().collect { data ->
-                adapter.setItems(data, adapter.diffItemCallback, !Debug.isChecking)
-                itemTouchCallback.isCanDrag =
-                    sort == BookSourceSort.Default && !groupSourcesByDomain
-                delay(500)
-            }
-        }
-    }
-
-    override fun onResume() {
-        super.onResume()
-        adapter.upResumed(true)
-    }
-
-    override fun onPause() {
-        adapter.upResumed(false)
-        super.onPause()
-    }
-
-    private fun initLiveDataGroup() {
-        lifecycleScope.launch {
-            appDb.bookSourceDao.flowGroups()
-                .flowWithLifecycleAndDatabaseChange(
-                    lifecycle,
-                    table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-                )
-                .flowWithLifecycleAndDatabaseChangeFirst(
-                    groupMenuLifecycleOwner.lifecycle,
-                    table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-                )
-                .conflate()
-                .distinctUntilChanged()
-                .collect {
-                    groups.clear()
-                    groups.addAll(it)
-                    upGroupMenu()
-                    delay(500)
-                }
-        }
-    }
-
-    override fun selectAll(selectAll: Boolean) {
-        if (selectAll) {
-            adapter.selectAll()
-        } else {
-            adapter.revertSelection()
-        }
-    }
-
-    override fun revertSelection() {
-        adapter.revertSelection()
-    }
-
-    override fun onClickSelectBarMainAction() {
-        alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
-            yesButton { viewModel.del(adapter.selection) }
-            noButton()
-        }
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        if (menu === groupMenu) {
-            groupMenuLifecycleOwner.onMenuOpened()
-        }
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onPanelClosed(featureId: Int, menu: Menu) {
-        super.onPanelClosed(featureId, menu)
-        if (menu === groupMenu) {
-            groupMenuLifecycleOwner.onMenuClosed()
-        }
-    }
-
-    private fun initSelectActionBar() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.book_source_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
-            R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
-            R.id.menu_enable_explore -> viewModel.enableSelectExplore(adapter.selection)
-            R.id.menu_disable_explore -> viewModel.disableSelectExplore(adapter.selection)
-            R.id.menu_check_source -> checkSource()
-            R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray())
-            R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray())
-            R.id.menu_add_group -> selectionAddToGroups()
-            R.id.menu_remove_group -> selectionRemoveFromGroups()
-            R.id.menu_export_selection -> viewModel.saveToFile(
-                adapter,
-                searchView.query?.toString(),
-                sortAscending,
-                sort
-            ) { file, name ->
-                exportDir.launch {
-                    mode = HandleFileContract.EXPORT
-                    fileData = HandleFileContract.FileData(
-                        name,
-                        file,
-                        "application/json"
+/** Legacy entry point retained for callers outside the Compose navigation graph. */
+class BookSourceActivity : BaseComposeActivity() {
+    private val importDocument =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+            uri?.let { showDialogFragment(ImportBookSourceDialog(it.toString())) }
+    }
+
+    @Composable
+    override fun Content() {
+        BookSourceRouteScreen(
+            onBackClick = ::finish,
+            onAddSource = { startActivity<BookSourceEditActivity>() },
+            onEditSource = { source ->
+                startActivity<BookSourceEditActivity> {
+                    putExtra(
+                        "sourceUrl",
+                        source.bookSourceUrl
                     )
                 }
-            }
-
-            R.id.menu_share_source -> viewModel.saveToFile(
-                adapter,
-                searchView.query?.toString(),
-                sortAscending,
-                sort
-            ) { file, name ->
-                share(file)
-            }
-
-            R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
-        }
-        return true
-    }
-
-    @SuppressLint("InflateParams")
-    private fun checkSource() {
-        val dialog = alert(titleResource = R.string.search_book_key) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "search word"
-                editView.setText(CheckSource.keyword)
-            }
-            customView { alertBinding.root }
-            okButton {
-                keepScreenOn(true)
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        CheckSource.keyword = it
-                    }
-                }
-                val selectItems = adapter.selection
-                CheckSource.start(this@BookSourceActivity, selectItems)
-                val adapterItems = adapter.getItems()
-                val firstItem = adapterItems.indexOf(selectItems.firstOrNull())
-                val lastItem = adapterItems.indexOf(selectItems.lastOrNull())
-                Debug.isChecking = firstItem >= 0 && lastItem >= 0
-                startCheckMessageRefreshJob(firstItem, lastItem)
-            }
-            neutralButton(R.string.check_source_config)
-            cancelButton()
-        }
-        //手动设置监听 避免点击打开校验设置后对话框关闭
-        dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
-            showDialogFragment<CheckSourceConfig>()
-        }
-    }
-
-    private fun resumeCheckSource() {
-        if (!Debug.isChecking) {
-            return
-        }
-        keepScreenOn(true)
-        CheckSource.resume(this)
-        startCheckMessageRefreshJob(0, 0)
-    }
-
-    @SuppressLint("InflateParams")
-    private fun selectionAddToGroups() {
-        alert(titleResource = R.string.add_group) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setFilterValues(groups.toList())
-                editView.dropDownHeight = 180.dpToPx()
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        viewModel.selectionAddToGroups(adapter.selection, it)
-                    }
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun selectionRemoveFromGroups() {
-        alert(titleResource = R.string.remove_group) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setFilterValues(groups.toList())
-                editView.dropDownHeight = 180.dpToPx()
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        viewModel.selectionRemoveFromGroups(adapter.selection, it)
-                    }
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    private fun upGroupMenu() = groupMenu?.transaction { menu ->
-        menu.removeGroup(R.id.source_group)
-        groups.forEach {
-            menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it)
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun showImportDialog() {
-        val aCache = ACache.get(cacheDir = false)
-        val cacheUrls: MutableList<String> = aCache
-            .getAsString(importRecordKey)
-            ?.splitNotBlank(",")
-            ?.toMutableList() ?: mutableListOf()
-        alert(titleResource = R.string.import_on_line) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "url"
-                editView.setFilterValues(cacheUrls)
-                editView.delCallBack = {
-                    cacheUrls.remove(it)
-                    aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                }
-            }
-            customView { alertBinding.root }
-            okButton {
-                val text = alertBinding.editView.text?.toString()
-                text?.let {
-                    if (it.isAbsUrl() && !cacheUrls.contains(it)) {
-                        cacheUrls.add(0, it)
-                        aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                    }
-                    showDialogFragment(ImportBookSourceDialog(it))
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    override fun observeLiveBus() {
-        observeEvent<String>(EventBus.CHECK_SOURCE) { msg ->
-            snackBar?.setText(msg) ?: let {
-                snackBar = Snackbar
-                    .make(binding.root, msg, Snackbar.LENGTH_INDEFINITE)
-                    .setAction(R.string.cancel) {
-                        CheckSource.stop(this)
-                        Debug.finishChecking()
-                    }.apply { show() }
-            }
-        }
-        observeEvent<Int>(EventBus.CHECK_SOURCE_DONE) {
-            keepScreenOn(false)
-            snackBar?.dismiss()
-            snackBar = null
-            adapter.notifyItemRangeChanged(
-                0,
-                adapter.itemCount,
-                bundleOf(Pair("checkSourceMessage", null))
-            )
-            groups.forEach { group ->
-                if (group.contains("失效") && searchView.query.isEmpty()) {
-                    searchView.setQuery("失效", true)
-                    toastOnUi("发现有失效书源,已为您自动筛选!")
-                }
-            }
-        }
-    }
-
-    private fun startCheckMessageRefreshJob(firstItem: Int, lastItem: Int) {
-        checkMessageRefreshJob?.cancel()
-        checkMessageRefreshJob = lifecycleScope.launch {
-            repeatOnLifecycle(Lifecycle.State.STARTED) {
-                while (isActive) {
-                    if (lastItem == 0) {
-                        adapter.notifyItemRangeChanged(
-                            0,
-                            adapter.itemCount,
-                            bundleOf(Pair("checkSourceMessage", null))
-                        )
-                    } else {
-                        adapter.notifyItemRangeChanged(
-                            firstItem,
-                            lastItem + 1,
-                            bundleOf(Pair("checkSourceMessage", null))
-                        )
-                    }
-                    if (!Debug.isChecking) {
-                        checkMessageRefreshJob?.cancel()
-                    }
-                    delay(300L)
-                }
-            }
-        }
-    }
-
-    /**
-     * 保持亮屏
-     */
-    private fun keepScreenOn(on: Boolean) {
-        val isScreenOn =
-            (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0
-        if (on == isScreenOn) return
-        if (on) {
-            window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-        } else {
-            window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-        }
-    }
-
-    override fun upCountView() {
-        binding.selectActionBar
-            .upCountView(adapter.selection.size, adapter.itemCount)
-    }
-
-    override fun getSourceHost(origin: String): String {
-        return hostMap.getOrPut(origin) {
-            NetworkUtils.getSubDomainOrNull(origin) ?: "#"
-        }
-    }
-
-    override fun onQueryTextChange(newText: String?): Boolean {
-        newText?.let {
-            upBookSource(it)
-        }
-        return false
-    }
-
-    override fun onQueryTextSubmit(query: String?): Boolean {
-        return false
-    }
-
-    override fun del(bookSource: BookSourcePart) {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n" + bookSource.bookSourceName)
-            noButton()
-            yesButton {
-                viewModel.del(listOf(bookSource))
-            }
-        }
-    }
-
-    override fun edit(bookSource: BookSourcePart) {
-        startActivity<BookSourceEditActivity> {
-            putExtra("sourceUrl", bookSource.bookSourceUrl)
-        }
-    }
-
-    override fun upOrder(items: List<BookSourcePart>) {
-        viewModel.upOrder(items)
-    }
-
-    override fun enable(enable: Boolean, bookSource: BookSourcePart) {
-        viewModel.enable(enable, listOf(bookSource))
-    }
-
-    override fun enableExplore(enable: Boolean, bookSource: BookSourcePart) {
-        viewModel.enableExplore(enable, listOf(bookSource))
-    }
-
-    override fun toTop(bookSource: BookSourcePart) {
-        if (sortAscending) {
-            viewModel.topSource(bookSource)
-        } else {
-            viewModel.bottomSource(bookSource)
-        }
-    }
-
-    override fun toBottom(bookSource: BookSourcePart) {
-        if (sortAscending) {
-            viewModel.bottomSource(bookSource)
-        } else {
-            viewModel.topSource(bookSource)
-        }
-    }
-
-    override fun searchBook(bookSource: BookSourcePart) {
-        startActivity<SearchActivity> {
-            putExtra("searchScope", SearchScope(bookSource).toString())
-        }
-    }
-
-    override fun debug(bookSource: BookSourcePart) {
-        startActivity<BookSourceDebugActivity> {
-            putExtra("key", bookSource.bookSourceUrl)
-        }
-    }
-
-    override fun finish() {
-        if (searchView.query.isNullOrEmpty()) {
-            super.finish()
-        } else {
-            searchView.setQuery("", true)
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        if (!Debug.isChecking) {
-            Debug.debugMessageMap.clear()
-        }
+            },
+            onImportLocal = {
+                importDocument.launch(
+                    arrayOf(
+                        "application/json",
+                        "text/plain",
+                        "text/*"
+                    )
+                )
+            },
+            onImportOnline = { text -> showDialogFragment(ImportBookSourceDialog(text)) },
+        )
     }
-
-}
+}

+ 65 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt

@@ -0,0 +1,65 @@
+package io.legado.app.ui.book.source.manage
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.ui.widget.components.list.InteractionState
+import io.legado.app.ui.widget.components.list.ListUiState
+import io.legado.app.ui.widget.components.list.SelectableItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+
+@Immutable
+data class BookSourceItemUi(
+    val source: BookSourcePart,
+    val domain: String,
+) : SelectableItem<String> {
+    override val id get() = source.bookSourceUrl
+}
+
+@Stable
+data class BookSourceUiState(
+    override val items: ImmutableList<BookSourceItemUi> = persistentListOf(),
+    override val selectedIds: ImmutableSet<String> = persistentSetOf(),
+    override val searchKey: String = "",
+    val groupFilterName: String? = null,
+    val activeFilter: String? = null,
+    val groups: ImmutableList<String> = persistentListOf(),
+    val sort: BookSourceSort = BookSourceSort.Default,
+    val sortAscending: Boolean = true,
+    val groupByDomain: Boolean = false,
+    val interaction: InteractionState = InteractionState(isLoading = true),
+) : ListUiState<BookSourceItemUi> {
+    override val isSearch get() = interaction.isSearchMode
+    override val isLoading get() = interaction.isLoading
+}
+
+sealed interface BookSourceIntent {
+    data class SetSearchMode(val enabled: Boolean) : BookSourceIntent
+    data class SetSearchQuery(val query: String) : BookSourceIntent
+    data class SetSelection(val ids: Set<String>) : BookSourceIntent
+    data class ToggleSelection(val id: String) : BookSourceIntent
+    data class SetFilter(val filter: String?) : BookSourceIntent
+    data class SetSort(val sort: BookSourceSort) : BookSourceIntent
+    data object ToggleSortDirection : BookSourceIntent
+    data object ToggleGroupByDomain : BookSourceIntent
+    data class SetEnabled(val id: String, val enabled: Boolean) : BookSourceIntent
+    data class SetEnabledForSelection(val ids: Set<String>, val enabled: Boolean) : BookSourceIntent
+    data class SetExploreEnabled(val ids: Set<String>, val enabled: Boolean) : BookSourceIntent
+    data class Delete(val ids: Set<String>) : BookSourceIntent
+    data class MoveToEdge(val ids: Set<String>, val toTop: Boolean) : BookSourceIntent
+    data class MoveItem(val from: Int, val to: Int) : BookSourceIntent
+    data object SaveSortOrder : BookSourceIntent
+    data class CommitSortOrder(
+        val ids: List<String>,
+        val ascending: Boolean,
+    ) : BookSourceIntent
+
+    data class AddToGroup(val ids: Set<String>, val group: String) : BookSourceIntent
+    data class RemoveFromGroup(val ids: Set<String>, val group: String) : BookSourceIntent
+    data class UpdateGroup(val old: String, val new: String) : BookSourceIntent
+    data class DeleteGroup(val group: String) : BookSourceIntent
+    data class CheckSelectedInterval(val ids: Set<String>) : BookSourceIntent
+}

+ 512 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -0,0 +1,512 @@
+package io.legado.app.ui.book.source.manage
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
+import io.legado.app.ui.widget.components.GroupManageBottomSheet
+import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.dialog.TextListInputDialog
+import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.rules.RuleListScaffold
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import org.koin.androidx.compose.koinViewModel
+import sh.calvin.reorderable.rememberReorderableLazyListState
+import androidx.compose.foundation.lazy.grid.items as gridItems
+
+@Composable
+fun BookSourceRouteScreen(
+    viewModel: BookSourceViewModel = koinViewModel(),
+    onBackClick: () -> Unit,
+    onAddSource: () -> Unit,
+    onEditSource: (BookSourcePart) -> Unit,
+    onImportLocal: () -> Unit,
+    onImportOnline: (String) -> Unit,
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    BookSourceScreen(
+        state,
+        viewModel::onIntent,
+        onBackClick,
+        onAddSource,
+        onEditSource,
+        onImportLocal,
+        onImportOnline
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun BookSourceScreen(
+    state: BookSourceUiState,
+    onIntent: (BookSourceIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onAddSource: () -> Unit,
+    onEditSource: (BookSourcePart) -> Unit,
+    onImportLocal: () -> Unit,
+    onImportOnline: (String) -> Unit,
+) {
+    val rules = state.items
+    val selectedIds = state.selectedIds
+    val listState = rememberLazyListState()
+    var deleteIds by remember { mutableStateOf<Set<String>?>(null) }
+    var addGroup by remember { mutableStateOf(false) }
+    var removeGroup by remember { mutableStateOf(false) }
+    var groupManage by remember { mutableStateOf(false) }
+    var showGroupFilterSheet by remember { mutableStateOf(false) }
+    var showOnlineImport by remember { mutableStateOf(false) }
+    var dragOrder by remember { mutableStateOf<List<BookSourceItemUi>?>(null) }
+    val displayedRules = dragOrder ?: rules
+    val reorderState = rememberReorderableLazyListState(listState) { from, to ->
+        val moved = (dragOrder ?: rules).toMutableList()
+        if (from.index in moved.indices && to.index in moved.indices) {
+            moved.add(to.index, moved.removeAt(from.index))
+            dragOrder = moved
+        }
+    }
+    LaunchedEffect(reorderState.isAnyItemDragging, rules) {
+        if (!reorderState.isAnyItemDragging) {
+            dragOrder?.let { pending ->
+                val pendingIds = pending.map { it.id }
+                if (rules.map { it.id } == pendingIds) {
+                    dragOrder = null
+                } else {
+                    onIntent(BookSourceIntent.CommitSortOrder(pendingIds, state.sortAscending))
+                }
+            }
+        }
+    }
+    SourceInputDialog(
+        show = showOnlineImport,
+        title = stringResource(R.string.import_on_line),
+        onDismissRequest = { showOnlineImport = false },
+        onConfirm = { text -> showOnlineImport = false; onImportOnline(text) })
+
+    BookSourceGroupFilterSheet(
+        show = showGroupFilterSheet,
+        state = state,
+        onDismissRequest = { showGroupFilterSheet = false },
+        onSelect = { value ->
+            showGroupFilterSheet = false
+            onIntent(BookSourceIntent.SetFilter(value))
+        },
+    )
+
+    TextListInputDialog(
+        show = addGroup,
+        title = stringResource(R.string.add_group),
+        hint = stringResource(R.string.group_name),
+        suggestions = state.groups,
+        onDismissRequest = { addGroup = false },
+        onConfirm = { onIntent(BookSourceIntent.AddToGroup(selectedIds, it)); addGroup = false })
+    TextListInputDialog(
+        show = removeGroup,
+        title = stringResource(R.string.remove_group),
+        hint = stringResource(R.string.group_name),
+        suggestions = state.groups,
+        onDismissRequest = { removeGroup = false },
+        onConfirm = {
+            onIntent(BookSourceIntent.RemoveFromGroup(selectedIds, it)); removeGroup = false
+        })
+    GroupManageBottomSheet(
+        groupManage, state.groups, { groupManage = false },
+        onUpdateGroup = { old, new -> onIntent(BookSourceIntent.UpdateGroup(old, new)) },
+        onDeleteGroup = { onIntent(BookSourceIntent.DeleteGroup(it)) }
+    )
+    AppAlertDialog(
+        deleteIds,
+        { deleteIds = null },
+        stringResource(R.string.delete),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { ids -> onIntent(BookSourceIntent.Delete(ids)); deleteIds = null },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { deleteIds = null })
+
+    RuleListScaffold(
+        title = stringResource(R.string.book_source),
+        subtitle = state.groupFilterName ?: stringResource(R.string.all),
+        state = state,
+        onBackClick = onBackClick,
+        onSearchToggle = { onIntent(BookSourceIntent.SetSearchMode(it)) },
+        onSearchQueryChange = { onIntent(BookSourceIntent.SetSearchQuery(it)) },
+        searchPlaceholder = stringResource(R.string.search_book_source),
+        onClearSelection = { onIntent(BookSourceIntent.SetSelection(emptySet())) },
+        onSelectAll = {
+            onIntent(BookSourceIntent.SetSelection(displayedRules.map { it.id }.toSet()))
+        },
+        onSelectInvert = {
+            onIntent(BookSourceIntent.SetSelection(displayedRules.map { it.id }
+                .toSet() - selectedIds))
+        },
+        topBarActions = {
+            TopBarActionButton(
+                onClick = { showGroupFilterSheet = true },
+                imageVector = Icons.Default.Folder,
+                contentDescription = stringResource(R.string.menu_action_group),
+            )
+        },
+        snackbarHostState = remember { SnackbarHostState() },
+        onAddClick = onAddSource,
+        selectionSecondaryActions = listOf(
+            ActionItem(stringResource(R.string.enable_selection)) {
+                onIntent(
+                    BookSourceIntent.SetEnabledForSelection(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.disable_selection)) {
+                onIntent(
+                    BookSourceIntent.SetEnabledForSelection(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.enable_explore)) {
+                onIntent(
+                    BookSourceIntent.SetExploreEnabled(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.disable_explore)) {
+                onIntent(
+                    BookSourceIntent.SetExploreEnabled(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.add_group)) { addGroup = true },
+            ActionItem(stringResource(R.string.remove_group)) { removeGroup = true },
+            ActionItem(stringResource(R.string.selection_to_top)) {
+                onIntent(
+                    BookSourceIntent.MoveToEdge(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.selection_to_bottom)) {
+                onIntent(
+                    BookSourceIntent.MoveToEdge(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.check_selected_interval)) {
+                onIntent(
+                    BookSourceIntent.CheckSelectedInterval(
+                        selectedIds
+                    )
+                )
+            },
+        ),
+        onDeleteSelected = { deleteIds = it as Set<String> },
+        dropDownMenuContent = { dismiss ->
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.group_manage),
+                onClick = { dismiss(); groupManage = true })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_local),
+                onClick = { dismiss(); onImportLocal() })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_on_line),
+                onClick = { dismiss(); showOnlineImport = true })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.group_sources_by_domain),
+                isSelected = state.groupByDomain,
+                onClick = { dismiss(); onIntent(BookSourceIntent.ToggleGroupByDomain) },
+            )
+            PillDivider()
+            SortMenuItem(R.string.sort_manual, BookSourceSort.Default, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_auto, BookSourceSort.Weight, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_by_name, BookSourceSort.Name, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_by_url, BookSourceSort.Url, state, dismiss, onIntent)
+            SortMenuItem(
+                R.string.sort_by_lastUpdateTime,
+                BookSourceSort.Update,
+                state,
+                dismiss,
+                onIntent
+            )
+            SortMenuItem(
+                R.string.sort_by_respondTime,
+                BookSourceSort.Respond,
+                state,
+                dismiss,
+                onIntent
+            )
+            SortMenuItem(R.string.is_enabled, BookSourceSort.Enable, state, dismiss, onIntent)
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.sort_desc),
+                isSelected = !state.sortAscending,
+                onClick = { dismiss(); onIntent(BookSourceIntent.ToggleSortDirection) },
+            )
+        }
+    ) { padding ->
+        Box(Modifier.fillMaxSize()) {
+            FastScrollLazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = listState,
+                contentPadding = adaptiveContentPadding(
+                    top = padding.calculateTopPadding(),
+                    bottom = padding.calculateBottomPadding() + 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                displayedRules.forEachIndexed { index, item ->
+                    if (state.groupByDomain && (index == 0 || displayedRules[index - 1].domain != item.domain)) {
+                        item(key = "domain:${item.domain}", contentType = "domain-header") {
+                            AppText(
+                                text = item.domain,
+                                style = LegadoTheme.typography.titleSmall,
+                                color = LegadoTheme.colorScheme.primary,
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 8.dp),
+                            )
+                        }
+                    }
+                    item(key = item.id, contentType = "book-source") {
+                        ReorderableSelectionItem(
+                            state = reorderState,
+                            key = item.id,
+                            reorderIndex = index,
+                            reorderItemCount = displayedRules.size,
+                            onMoveItem = { from, to ->
+                                val moved = displayedRules.toMutableList()
+                                if (from in moved.indices && to in moved.indices) {
+                                    moved.add(to, moved.removeAt(from))
+                                    dragOrder = moved
+                                    onIntent(
+                                        BookSourceIntent.CommitSortOrder(
+                                            moved.map { it.id },
+                                            state.sortAscending
+                                        )
+                                    )
+                                }
+                            },
+                            title = item.source.bookSourceName,
+                            subtitle = item.source.bookSourceGroup,
+                            isEnabled = item.source.enabled,
+                            isSelected = item.id in selectedIds,
+                            canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                            inSelectionMode = selectedIds.isNotEmpty(),
+                            onToggleSelection = { onIntent(BookSourceIntent.ToggleSelection(item.id)) },
+                            onEnabledChange = {
+                                onIntent(
+                                    BookSourceIntent.SetEnabled(
+                                        item.id,
+                                        it
+                                    )
+                                )
+                            },
+                            onClickEdit = { onEditSource(item.source) },
+                            trailingAction = {
+                                SmallPlainButton(
+                                    icon = Icons.Default.Delete,
+                                    contentDescription = stringResource(R.string.delete),
+                                    onClick = { deleteIds = setOf(item.id) })
+                            })
+                    }
+                }
+            }
+            if (selectedIds.isNotEmpty()) DraggableSelectionHandler(
+                listState = listState,
+                items = displayedRules,
+                selectedIds = selectedIds,
+                onSelectionChange = { onIntent(BookSourceIntent.SetSelection(it)) },
+                idProvider = { it.id },
+                modifier = Modifier
+                    .fillMaxHeight()
+                    .width(60.dp)
+                    .align(Alignment.TopStart)
+            )
+        }
+    }
+}
+
+@Composable
+private fun SortMenuItem(
+    textRes: Int,
+    sort: BookSourceSort,
+    state: BookSourceUiState,
+    dismiss: () -> Unit,
+    onIntent: (BookSourceIntent) -> Unit,
+) = RoundDropdownMenuItem(
+    text = stringResource(textRes),
+    isSelected = state.sort == sort,
+    onClick = { dismiss(); onIntent(BookSourceIntent.SetSort(sort)) },
+)
+
+@Composable
+private fun BookSourceGroupFilterSheet(
+    show: Boolean,
+    state: BookSourceUiState,
+    onDismissRequest: () -> Unit,
+    onSelect: (String?) -> Unit,
+) {
+    var query by remember(show) { mutableStateOf("") }
+    val defaultOptions = listOf(
+        stringResource(R.string.all) to null,
+        stringResource(R.string.enabled) to BookSourceViewModel.FILTER_ENABLED,
+        stringResource(R.string.disabled) to BookSourceViewModel.FILTER_DISABLED,
+        stringResource(R.string.need_login) to BookSourceViewModel.FILTER_LOGIN,
+        stringResource(R.string.no_group) to BookSourceViewModel.FILTER_NO_GROUP,
+        stringResource(R.string.enabled_explore) to BookSourceViewModel.FILTER_ENABLED_EXPLORE,
+        stringResource(R.string.disabled_explore) to BookSourceViewModel.FILTER_DISABLED_EXPLORE,
+    )
+    val otherOptions = state.groups.map { group ->
+        group to "${BookSourceViewModel.PREFIX_GROUP}$group"
+    }
+    val filteredDefaultOptions = remember(defaultOptions, query) {
+        if (query.isBlank()) defaultOptions else defaultOptions.filter { (label, _) ->
+            label.contains(query, ignoreCase = true)
+        }
+    }
+    val filteredOtherOptions = remember(otherOptions, query) {
+        if (query.isBlank()) otherOptions else otherOptions.filter { (label, _) ->
+            label.contains(query, ignoreCase = true)
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.menu_action_group),
+    ) {
+        Column(Modifier.fillMaxWidth()) {
+            SearchBar(
+                query = query,
+                onQueryChange = { query = it },
+                placeholder = stringResource(R.string.search_placeholder),
+                autoFocus = false,
+            )
+            Spacer(Modifier.height(8.dp))
+            LazyVerticalGrid(
+                columns = GridCells.Fixed(2),
+                modifier = Modifier.height(420.dp),
+                contentPadding = PaddingValues(vertical = 4.dp),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalArrangement = Arrangement.spacedBy(6.dp),
+            ) {
+                if (filteredDefaultOptions.isNotEmpty()) {
+                    item(key = "default-groups-header", span = { GridItemSpan(maxLineSpan) }) {
+                        GroupFilterSectionTitle(stringResource(R.string.book_source_default_groups))
+                    }
+                }
+                gridItems(
+                    filteredDefaultOptions,
+                    key = { (_, value) -> value ?: "@all" }) { (label, value) ->
+                    GroupFilterItem(label, value, state.activeFilter, onSelect)
+                }
+                if (filteredOtherOptions.isNotEmpty()) {
+                    item(key = "other-groups-header", span = { GridItemSpan(maxLineSpan) }) {
+                        GroupFilterSectionTitle(stringResource(R.string.book_source_other_groups))
+                    }
+                }
+                gridItems(filteredOtherOptions, key = { (_, value) -> value }) { (label, value) ->
+                    GroupFilterItem(label, value, state.activeFilter, onSelect)
+                }
+            }
+            Spacer(Modifier.height(12.dp))
+        }
+    }
+}
+
+@Composable
+private fun GroupFilterSectionTitle(text: String) {
+    AppText(
+        text = text,
+        style = LegadoTheme.typography.labelMedium,
+        color = LegadoTheme.colorScheme.onSurfaceVariant,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 4.dp, vertical = 6.dp),
+    )
+}
+
+@Composable
+private fun GroupFilterItem(
+    label: String,
+    value: String?,
+    activeFilter: String?,
+    onSelect: (String?) -> Unit,
+) {
+    val selected = activeFilter == value
+    Box(
+        contentAlignment = Alignment.Center,
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(shape = RoundedCornerShape(12.dp))
+            .background(
+                color = if (selected) LegadoTheme.colorScheme.primaryContainer
+                else LegadoTheme.colorScheme.onSheetContent
+            )
+            .clickable { onSelect(value) }
+            .padding(horizontal = 12.dp, vertical = 8.dp),
+    ) {
+        AppText(
+            text = label,
+            style = LegadoTheme.typography.labelMediumEmphasized,
+            color = if (selected) LegadoTheme.colorScheme.onPrimaryContainer
+            else LegadoTheme.colorScheme.onSurface,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            textAlign = TextAlign.Center,
+        )
+    }
+}

+ 230 - 285
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt

@@ -2,302 +2,247 @@ package io.legado.app.ui.book.source.manage
 
 import android.app.Application
 import android.text.TextUtils
-import io.legado.app.R
-import io.legado.app.base.BaseViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
 import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.data.entities.toBookSource
 import io.legado.app.help.source.SourceHelp
-import io.legado.app.utils.FileUtils
-import io.legado.app.utils.GSON
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.normalizeFileName
-import io.legado.app.utils.outputStream
+import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.splitNotBlank
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.writeToOutputStream
-import splitties.init.appCtx
-import java.io.File
-import java.util.Date
-import java.util.Locale
-
-/**
- * 书源管理数据修改
- * 修改数据要copy,直接修改会导致界面不刷新
- */
-class BookSourceViewModel(application: Application) : BaseViewModel(application) {
-
-    fun topSource(vararg sources: BookSourcePart) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val minOrder = appDb.bookSourceDao.minOrder - 1
-            val array = sources.mapIndexed { index, it ->
-                it.copy(customOrder = minOrder - index)
-            }
-            appDb.bookSourceDao.upOrder(array)
-        }
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class BookSourceViewModel(application: Application) : ViewModel() {
+    companion object {
+        const val FILTER_ENABLED = "@enabled"
+        const val FILTER_DISABLED = "@disabled"
+        const val FILTER_LOGIN = "@login"
+        const val FILTER_NO_GROUP = "@noGroup"
+        const val FILTER_ENABLED_EXPLORE = "@enabledExplore"
+        const val FILTER_DISABLED_EXPLORE = "@disabledExplore"
+        const val PREFIX_GROUP = "group:"
+    }
+
+    private val dao = appDb.bookSourceDao
+    private val searchKey = MutableStateFlow("")
+    private val filter = MutableStateFlow<String?>(null)
+    private val selectedIds = MutableStateFlow<Set<String>>(emptySet())
+    private val sort = MutableStateFlow(BookSourceSort.Default)
+    private val sortAscending = MutableStateFlow(true)
+    private val groupByDomain = MutableStateFlow(false)
+    private val localItems = MutableStateFlow<List<BookSourcePart>?>(null)
+
+    val uiState = combine(
+        dao.flowAll(),
+        dao.flowGroups(),
+        searchKey,
+        filter,
+        selectedIds,
+        sort,
+        sortAscending,
+        groupByDomain,
+        localItems
+    ) { values ->
+        @Suppress("UNCHECKED_CAST")
+        val sourceItems = (values[0] as List<BookSourcePart>)
+        val groups = values[1] as List<String>
+        val query = values[2] as String
+        val activeFilter = values[3] as String?
+        val selected = values[4] as Set<String>
+        val activeSort = values[5] as BookSourceSort
+        val ascending = values[6] as Boolean
+        val byDomain = values[7] as Boolean
+        val local = values[8] as List<BookSourcePart>?
+        val visible = local ?: sourceItems.filterFor(activeFilter, query)
+            .sortFor(activeSort, ascending, byDomain)
+        BookSourceUiState(
+            items = visible.map { source ->
+                BookSourceItemUi(
+                    source = source,
+                    domain = NetworkUtils.getSubDomainOrNull(source.bookSourceUrl) ?: "#",
+                )
+            }.toImmutableList(),
+            selectedIds = selected.intersect(visible.map { it.bookSourceUrl }.toSet())
+                .toImmutableSet(),
+            searchKey = query,
+            groupFilterName = activeFilter?.displayName(application),
+            activeFilter = activeFilter,
+            groups = groups.toImmutableList(),
+            sort = activeSort,
+            sortAscending = ascending,
+            groupByDomain = byDomain,
+            interaction = io.legado.app.ui.widget.components.list.InteractionState(isSearchMode = query.isNotEmpty()),
+        )
+    }.flowOn(Dispatchers.Default)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BookSourceUiState())
+
+    fun onIntent(intent: BookSourceIntent) {
+        when (intent) {
+            is BookSourceIntent.SetSearchMode -> {
+                if (!intent.enabled) searchKey.value = ""
+            }
+
+            is BookSourceIntent.SetSearchQuery -> {
+                localItems.value = null; searchKey.value = intent.query
+            }
+
+            is BookSourceIntent.SetSelection -> selectedIds.value = intent.ids
+            is BookSourceIntent.ToggleSelection -> selectedIds.update { if (intent.id in it) it - intent.id else it + intent.id }
+            is BookSourceIntent.SetFilter -> {
+                localItems.value = null; filter.value = intent.filter
+            }
+
+            is BookSourceIntent.SetSort -> {
+                localItems.value = null; sort.value = intent.sort
+            }
+
+            BookSourceIntent.ToggleSortDirection -> {
+                localItems.value = null; sortAscending.update { !it }
+            }
+
+            BookSourceIntent.ToggleGroupByDomain -> {
+                localItems.value = null; groupByDomain.update { !it }
+            }
+
+            is BookSourceIntent.SetEnabled -> launch { dao.enable(intent.id, intent.enabled) }
+            is BookSourceIntent.SetEnabledForSelection -> launch {
+                dao.enable(
+                    intent.enabled,
+                    parts(intent.ids)
+                )
+            }
+
+            is BookSourceIntent.SetExploreEnabled -> updateExplore(intent.ids, intent.enabled)
+            is BookSourceIntent.Delete -> launch { SourceHelp.deleteBookSourceParts(parts(intent.ids)); selectedIds.update { it - intent.ids } }
+            is BookSourceIntent.MoveToEdge -> moveToEdge(intent.ids, intent.toTop)
+            is BookSourceIntent.MoveItem -> moveItem(intent.from, intent.to)
+            BookSourceIntent.SaveSortOrder -> saveSortOrder()
+            is BookSourceIntent.CommitSortOrder -> commitSortOrder(intent.ids, intent.ascending)
+            is BookSourceIntent.AddToGroup -> updateGroups(intent.ids, intent.group, true)
+            is BookSourceIntent.RemoveFromGroup -> updateGroups(intent.ids, intent.group, false)
+            is BookSourceIntent.UpdateGroup -> updateGroup(intent.old, intent.new)
+            is BookSourceIntent.DeleteGroup -> updateGroup(intent.group, "")
+            is BookSourceIntent.CheckSelectedInterval -> checkInterval(intent.ids)
+        }
+    }
+
+    private fun launch(block: suspend () -> Unit) =
+        viewModelScope.launch(Dispatchers.IO) { block() }
+
+    private fun parts(ids: Set<String>) = dao.allPart.filter { it.bookSourceUrl in ids }
+    private fun updateExplore(ids: Set<String>, enabled: Boolean) =
+        launch { dao.enableExplore(enabled, parts(ids)) }
+
+    private fun updateGroups(ids: Set<String>, group: String, add: Boolean) = launch {
+        val changed = parts(ids).map { part ->
+            part.copy().apply { if (add) addGroup(group) else removeGroup(group) }
+        }
+        dao.upGroup(changed)
+    }
+
+    private fun updateGroup(old: String, new: String) = launch {
+        val sources = dao.getByGroup(old)
+        sources.forEach { source ->
+            source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()
+                ?.apply { remove(old); if (new.isNotBlank()) add(new) }
+                ?.let { source.bookSourceGroup = TextUtils.join(",", it) }
+        }
+        dao.update(*sources.toTypedArray())
+    }
+
+    private fun moveToEdge(ids: Set<String>, toTop: Boolean) = launch {
+        val selected = parts(ids).sortedBy { it.customOrder }
+        val start = if (toTop) dao.minOrder - selected.size else dao.maxOrder + 1
+        dao.upOrder(selected.mapIndexed { index, part -> part.copy(customOrder = start + index) })
+    }
+
+    private fun moveItem(from: Int, to: Int) {
+        if (sort.value != BookSourceSort.Default || groupByDomain.value) return
+        val moved = (localItems.value ?: uiState.value.items.map { it.source }).toMutableList()
+        if (from !in moved.indices || to !in moved.indices) return
+        moved.add(to, moved.removeAt(from)); localItems.value = moved
     }
 
-    fun bottomSource(vararg sources: BookSourcePart) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val maxOrder = appDb.bookSourceDao.maxOrder + 1
-            val array = sources.mapIndexed { index, it ->
-                it.copy(customOrder = maxOrder + index)
-            }
-            appDb.bookSourceDao.upOrder(array)
+    private fun saveSortOrder() {
+        val items = localItems.value ?: return
+        launch {
+            dao.upOrder(items.mapIndexed { index, item -> item.copy(customOrder = index + 1) }); localItems.value =
+            null
         }
     }
 
-    fun del(sources: List<BookSourcePart>) {
-        execute {
-            SourceHelp.deleteBookSourceParts(sources)
+    private fun commitSortOrder(ids: List<String>, ascending: Boolean) = launch {
+        val sourcesById = dao.allPart.associateBy { it.bookSourceUrl }
+        val orderSlots = ids.mapNotNull { sourcesById[it]?.customOrder }
+            .let { if (ascending) it.sorted() else it.sortedDescending() }
+        val ordered = ids.mapIndexedNotNull { index, id ->
+            sourcesById[id]?.copy(
+                customOrder = orderSlots.getOrNull(index) ?: return@mapIndexedNotNull null
+            )
         }
+        dao.upOrder(ordered)
     }
 
-    fun update(vararg bookSource: BookSource) {
-        execute { appDb.bookSourceDao.update(*bookSource) }
-    }
-
-    fun upOrder(items: List<BookSourcePart>) {
-        if (items.isEmpty()) return
-        execute {
-            appDb.bookSourceDao.upOrder(items)
-        }
-    }
-
-    fun enable(enable: Boolean, items: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(enable, items)
-        }
-    }
-
-    fun enableSelection(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(true, sources)
-        }
-    }
-
-    fun disableSelection(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(false, sources)
-        }
-    }
-
-    fun enableExplore(enable: Boolean, items: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(enable, items)
-        }
-    }
-
-    fun enableSelectExplore(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(true, sources)
-        }
-    }
-
-    fun disableSelectExplore(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(false, sources)
-        }
-    }
-
-    fun selectionAddToGroups(sources: List<BookSourcePart>, groups: String) {
-        execute {
-            val array = sources.map {
-                it.copy().apply {
-                    addGroup(groups)
-                }
-            }
-            appDb.bookSourceDao.upGroup(array)
-        }
-    }
-
-    fun selectionRemoveFromGroups(sources: List<BookSourcePart>, groups: String) {
-        execute {
-            val array = sources.map {
-                it.copy().apply {
-                    removeGroup(groups)
-                }
-            }
-            appDb.bookSourceDao.upGroup(array)
-        }
-    }
-
-    private fun saveToFile(
-        sources: List<BookSource>,
-        name: String,
-        success: (file: File, name: String) -> Unit
-    ) {
-        execute {
-            val path = "${context.filesDir}/shareBookSource.json"
-            FileUtils.delete(path)
-            val file = FileUtils.createFileWithReplace(path)
-            file.outputStream().buffered().use {
-                GSON.writeToOutputStream(it, sources)
-            }
-            file
-        }.onSuccess {
-            success.invoke(it, name)
-        }.onError {
-            context.toastOnUi(it.stackTraceStr)
-        }
-    }
-
-    fun saveToFile(
-        adapter: BookSourceAdapter,
-        searchKey: String?,
-        sortAscending: Boolean,
-        sort: BookSourceSort,
-        success: (file: File, name: String) -> Unit
-    ) {
-        execute {
-            val selection = adapter.selection
-            val selectionSize = selection.size
-            val selectedRate = selectionSize.toFloat() / adapter.itemCount.toFloat()
-            val sources = if (selectedRate == 1f) {
-                getBookSources(searchKey, sortAscending, sort)
-            } else if (selectedRate < 0.3) {
-                selection.toBookSource()
-            } else {
-                val keys = selection.map { it.bookSourceUrl }.toHashSet()
-                val bookSources = getBookSources(searchKey, sortAscending, sort)
-                bookSources.filter {
-                    keys.contains(it.bookSourceUrl)
-                }
-            }
-            val name = if (selectionSize == 1) {
-                "bookSource_${selection.first().bookSourceName.normalizeFileName()}.json"
-            } else {
-                val timestamp =
-                    java.text.SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault()).format(Date())
-                "bookSource_$timestamp.json"
-            }
-            saveToFile(sources, name, success)
-        }
-    }
-
-    private fun getBookSources(
-        searchKey: String?,
-        sortAscending: Boolean,
-        sort: BookSourceSort
-    ): List<BookSource> {
-        return when {
-            searchKey.isNullOrEmpty() -> {
-                appDb.bookSourceDao.all
-            }
-
-            searchKey == appCtx.getString(R.string.enabled) -> {
-                appDb.bookSourceDao.allEnabled
-            }
-
-            searchKey == appCtx.getString(R.string.disabled) -> {
-                appDb.bookSourceDao.allDisabled
-            }
-
-            searchKey == appCtx.getString(R.string.need_login) -> {
-                appDb.bookSourceDao.allLogin
-            }
-
-            searchKey == appCtx.getString(R.string.no_group) -> {
-                appDb.bookSourceDao.allNoGroup
-            }
-
-            searchKey == appCtx.getString(R.string.enabled_explore) -> {
-                appDb.bookSourceDao.allEnabledExplore
-            }
-
-            searchKey == appCtx.getString(R.string.disabled_explore) -> {
-                appDb.bookSourceDao.allDisabledExplore
-            }
-
-            searchKey.startsWith("group:") -> {
-                val key = searchKey.substringAfter("group:")
-                appDb.bookSourceDao.groupSearch(key)
-            }
-
-            else -> {
-                appDb.bookSourceDao.search(searchKey)
-            }
-        }.let { data ->
-            if (sortAscending) when (sort) {
-                BookSourceSort.Weight -> data.sortedBy { it.weight }
-                BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                    o1.bookSourceName.cnCompare(o2.bookSourceName)
-                }
-
-                BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl }
-                BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime }
-                BookSourceSort.Respond -> data.sortedBy { it.respondTime }
-                BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                    var sortNum = -o1.enabled.compareTo(o2.enabled)
-                    if (sortNum == 0) {
-                        sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                    }
-                    sortNum
-                }
-
-                else -> data
-            }
-            else when (sort) {
-                BookSourceSort.Weight -> data.sortedByDescending { it.weight }
-                BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                    o2.bookSourceName.cnCompare(o1.bookSourceName)
-                }
-
-                BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl }
-                BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime }
-                BookSourceSort.Respond -> data.sortedByDescending { it.respondTime }
-                BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                    var sortNum = o1.enabled.compareTo(o2.enabled)
-                    if (sortNum == 0) {
-                        sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                    }
-                    sortNum
-                }
-
-                else -> data.reversed()
-            }
-        }
-    }
-
-    fun addGroup(group: String) {
-        execute {
-            val sources = appDb.bookSourceDao.noGroup
-            sources.forEach { source ->
-                source.bookSourceGroup = group
-            }
-            appDb.bookSourceDao.update(*sources.toTypedArray())
-        }
-    }
-
-    fun upGroup(oldGroup: String, newGroup: String?) {
-        execute {
-            val sources = appDb.bookSourceDao.getByGroup(oldGroup)
-            sources.forEach { source ->
-                source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
-                    it.remove(oldGroup)
-                    if (!newGroup.isNullOrEmpty())
-                        it.add(newGroup)
-                    source.bookSourceGroup = TextUtils.join(",", it)
-                }
-            }
-            appDb.bookSourceDao.update(*sources.toTypedArray())
-        }
-    }
-
-    fun delGroup(group: String) {
-        execute {
-            execute {
-                val sources = appDb.bookSourceDao.getByGroup(group)
-                sources.forEach { source ->
-                    source.removeGroup(group)
-                }
-                appDb.bookSourceDao.update(*sources.toTypedArray())
-            }
-        }
+    private fun checkInterval(ids: Set<String>) {
+        val items = uiState.value.items
+        val positions = items.mapIndexedNotNull { index, item -> index.takeIf { item.id in ids } }
+        if (positions.isNotEmpty()) selectedIds.value =
+            items.subList(positions.min(), positions.max() + 1).map { it.id }.toSet()
     }
+}
 
-}
+private fun List<BookSourcePart>.filterFor(filter: String?, query: String): List<BookSourcePart> =
+    filter { source ->
+        val filterMatch = when (filter) {
+            null -> true; BookSourceViewModel.FILTER_ENABLED -> source.enabled; BookSourceViewModel.FILTER_DISABLED -> !source.enabled
+            BookSourceViewModel.FILTER_LOGIN -> source.hasLoginUrl; BookSourceViewModel.FILTER_NO_GROUP -> source.bookSourceGroup.isNullOrBlank()
+            BookSourceViewModel.FILTER_ENABLED_EXPLORE -> source.enabledExplore; BookSourceViewModel.FILTER_DISABLED_EXPLORE -> !source.enabledExplore
+            else -> filter.startsWith(BookSourceViewModel.PREFIX_GROUP) && source.bookSourceGroup?.split(
+                ","
+            )?.contains(filter.removePrefix(BookSourceViewModel.PREFIX_GROUP)) == true
+        }
+        filterMatch && (query.isBlank() || listOf(
+            source.bookSourceName,
+            source.bookSourceUrl,
+            source.bookSourceGroup
+        ).any { it?.contains(query, true) == true })
+    }
+
+private fun List<BookSourcePart>.sortFor(
+    sort: BookSourceSort,
+    ascending: Boolean,
+    byDomain: Boolean
+): List<BookSourcePart> {
+    if (byDomain) {
+        val domains = associateWith { NetworkUtils.getSubDomainOrNull(it.bookSourceUrl) ?: "#" }
+        return sortedWith(compareBy<BookSourcePart> { domains.getValue(it) == "#" }
+            .thenBy { domains.getValue(it) }
+            .thenByDescending { it.lastUpdateTime })
+    }
+    val comparator = when (sort) {
+        BookSourceSort.Name -> compareBy<BookSourcePart> { it.bookSourceName }
+        BookSourceSort.Url -> compareBy { it.bookSourceUrl }; BookSourceSort.Weight -> compareBy { it.weight }
+        BookSourceSort.Update -> compareByDescending<BookSourcePart> { it.lastUpdateTime }; BookSourceSort.Respond -> compareBy { it.respondTime }
+        BookSourceSort.Enable -> compareByDescending<BookSourcePart> { it.enabled }.thenBy { it.bookSourceName }
+        BookSourceSort.Default -> compareBy { it.customOrder }
+    }
+    return if (ascending) sortedWith(comparator) else sortedWith(comparator.reversed())
+}
+
+private fun String.displayName(application: Application) = when (this) {
+    BookSourceViewModel.FILTER_ENABLED -> application.getString(io.legado.app.R.string.enabled)
+    BookSourceViewModel.FILTER_DISABLED -> application.getString(io.legado.app.R.string.disabled)
+    BookSourceViewModel.FILTER_LOGIN -> application.getString(io.legado.app.R.string.need_login)
+    BookSourceViewModel.FILTER_NO_GROUP -> application.getString(io.legado.app.R.string.no_group)
+    BookSourceViewModel.FILTER_ENABLED_EXPLORE -> application.getString(io.legado.app.R.string.enabled_explore)
+    BookSourceViewModel.FILTER_DISABLED_EXPLORE -> application.getString(io.legado.app.R.string.disabled_explore)
+    else -> removePrefix(BookSourceViewModel.PREFIX_GROUP)
+}

+ 0 - 137
app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt

@@ -1,137 +0,0 @@
-package io.legado.app.ui.book.source.manage
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.BaseDialogFragment
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.appDb
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.databinding.DialogRecyclerViewBinding
-import io.legado.app.databinding.ItemGroupManageBinding
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.backgroundColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.requestInputMethod
-import io.legado.app.utils.setLayout
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.launch
-
-
-class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
-    Toolbar.OnMenuItemClickListener {
-
-    private val viewModel: BookSourceViewModel by activityViewModels()
-    private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val adapter by lazy { GroupAdapter(requireContext()) }
-
-    override fun onStart() {
-        super.onStart()
-        setLayout(0.9f, 0.9f)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-//        view.setBackgroundColor(backgroundColor)
-//        binding.toolBar.setBackgroundColor(primaryColor)
-        binding.toolBar.title = getString(R.string.group_manage)
-        binding.toolBar.inflateMenu(R.menu.group_manage)
-        //binding.toolBar.menu.applyTint(requireContext())
-        binding.toolBar.setOnMenuItemClickListener(this)
-        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
-        binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
-        binding.recyclerView.adapter = adapter
-        initData()
-    }
-
-    private fun initData() {
-        lifecycleScope.launch {
-            appDb.bookSourceDao.flowGroups().collect {
-                adapter.setItems(it)
-            }
-        }
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_add -> addGroup()
-        }
-        return true
-    }
-
-    @SuppressLint("InflateParams")
-    private fun addGroup() {
-        alert(title = getString(R.string.add_group)) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotBlank()) {
-                        viewModel.addGroup(it)
-                    }
-                }
-            }
-            cancelButton()
-        }.requestInputMethod()
-    }
-
-    @SuppressLint("InflateParams")
-    private fun editGroup(group: String) {
-        alert(title = getString(R.string.group_edit)) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setText(group)
-            }
-            customView { alertBinding.root }
-            okButton {
-                viewModel.upGroup(group, alertBinding.editView.text?.toString())
-            }
-            cancelButton()
-        }.requestInputMethod()
-    }
-
-    private inner class GroupAdapter(context: Context) :
-        RecyclerAdapter<String, ItemGroupManageBinding>(context) {
-
-        override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
-            return ItemGroupManageBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemGroupManageBinding,
-            item: String,
-            payloads: MutableList<Any>
-        ) {
-            binding.run {
-                //root.setBackgroundColor(context.backgroundColor)
-                tvGroup.text = item
-            }
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
-            binding.apply {
-                tvEdit.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let {
-                        editGroup(it)
-                    }
-                }
-                tvDel.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) }
-                }
-            }
-        }
-    }
-
-}

+ 2 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -667,6 +667,8 @@
     <string name="group_manage">分组管理</string>
     <string name="group_add">新建分组</string>
     <string name="group_select">分组选择</string>
+    <string name="book_source_default_groups">默认分组</string>
+    <string name="book_source_other_groups">其他分组</string>
     <string name="group_edit">编辑分组</string>
     <string name="move_to_group">移入分组</string>
     <string name="add_group">添加分组</string>

+ 2 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -598,6 +598,8 @@
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
+    <string name="book_source_default_groups">預設分組</string>
+    <string name="book_source_other_groups">其他分組</string>
     <string name="group_edit">編輯分組</string>
     <string name="move_to_group">移入分組</string>
     <string name="add_group">添加分組</string>

+ 2 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -607,6 +607,8 @@
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
+    <string name="book_source_default_groups">預設分組</string>
+    <string name="book_source_other_groups">其他分組</string>
     <string name="group_edit">編輯分組</string>
     <string name="move_to_group">移入分組</string>
     <string name="add_group">新增分組</string>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -702,6 +702,8 @@
     <string name="group_manage">Group Management</string>
     <string name="group_add">New Group</string>
     <string name="group_select">Group selection</string>
+    <string name="book_source_default_groups">Default groups</string>
+    <string name="book_source_other_groups">Other groups</string>
     <string name="group_edit">Group editing</string>
     <string name="move_to_group">Move to group</string>
     <string name="add_group">Add to Groups</string>