Преглед изворни кода

[新增] 基于Compose的替换规则页面,功能仍不完善

HapeLee пре 10 месеци
родитељ
комит
bfb08a8541

+ 15 - 0
app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt

@@ -120,6 +120,21 @@ interface ReplaceRuleDao {
     )
     fun findEnabledByTitleScope(name: String, origin: String): List<ReplaceRule>
 
+    @Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id = :id")
+    suspend fun updateEnabled(id: Long, enabled: Boolean)
+
+    @Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id IN (:ids)")
+    suspend fun updateEnabled(ids: List<Long>, enabled: Boolean)
+
+    @Query("DELETE FROM replace_rules WHERE id IN (:ids)")
+    suspend fun deleteByIds(ids: List<Long>)
+
+    @Query("UPDATE replace_rules SET sortOrder = :order WHERE id = :id")
+    suspend fun updateOrder(id: Long, order: Int)
+
+    @Query("SELECT * FROM replace_rules WHERE id IN (:ids)")
+    fun getByIds(ids: Set<Long>): List<ReplaceRule>
+
     @Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)")
     suspend fun clearGroups(groups: List<String>)
 

+ 173 - 0
app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt

@@ -0,0 +1,173 @@
+package io.legado.app.data.repository
+
+import android.text.TextUtils
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.utils.splitNotBlank
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class ReplaceRuleRepository {
+
+    fun flowGroups(): Flow<List<String>> {
+        return appDb.replaceRuleDao.flowGroups().flowOn(Dispatchers.IO)
+    }
+
+    fun flowAll(): Flow<List<ReplaceRule>> {
+        return appDb.replaceRuleDao.flowAll().flowOn(Dispatchers.IO)
+    }
+
+    fun flowNoGroup(): Flow<List<ReplaceRule>> {
+        return appDb.replaceRuleDao.flowNoGroup().flowOn(Dispatchers.IO)
+    }
+
+    fun flowGroupSearch(key: String): Flow<List<ReplaceRule>> {
+        return appDb.replaceRuleDao.flowGroupSearch(key).flowOn(Dispatchers.IO)
+    }
+
+    fun flowSearch(key: String): Flow<List<ReplaceRule>> {
+        return appDb.replaceRuleDao.flowSearch(key).flowOn(Dispatchers.IO)
+    }
+
+    suspend fun update(vararg rule: ReplaceRule) {
+        withContext(Dispatchers.IO) {
+            appDb.replaceRuleDao.update(*rule)
+        }
+    }
+
+    suspend fun delete(rule: ReplaceRule) {
+        withContext(Dispatchers.IO) {
+            appDb.replaceRuleDao.delete(rule)
+        }
+    }
+
+    suspend fun toTop(rule: ReplaceRule) {
+        withContext(Dispatchers.IO) {
+            rule.order = -1
+            appDb.replaceRuleDao.update(rule)
+        }
+    }
+
+    suspend fun toBottom(rule: ReplaceRule) {
+        withContext(Dispatchers.IO) {
+            rule.order = -2
+            appDb.replaceRuleDao.update(rule)
+        }
+    }
+
+    suspend fun upOrder() {
+        withContext(Dispatchers.IO) {
+            val rules = appDb.replaceRuleDao.all
+            var normalOrder = 1
+            rules.forEach { rule ->
+                if (rule.order >= 0) {
+                    rule.order = normalOrder++
+                }
+            }
+            appDb.replaceRuleDao.update(*rules.toTypedArray())
+        }
+    }
+
+    suspend fun enableSelection(rules: List<ReplaceRule>) {
+        withContext(Dispatchers.IO) {
+            val array = Array(rules.size) {
+                rules[it].copy(isEnabled = true)
+            }
+            appDb.replaceRuleDao.update(*array)
+        }
+    }
+
+    suspend fun disableSelection(rules: List<ReplaceRule>) {
+        withContext(Dispatchers.IO) {
+            val array = Array(rules.size) {
+                rules[it].copy(isEnabled = false)
+            }
+            appDb.replaceRuleDao.update(*array)
+        }
+    }
+
+    suspend fun addGroup(group: String) {
+        withContext(Dispatchers.IO) {
+            val sources = appDb.replaceRuleDao.noGroup
+            sources.forEach { source ->
+                source.group = group
+            }
+            appDb.replaceRuleDao.update(*sources.toTypedArray())
+        }
+    }
+
+    suspend fun upGroup(oldGroup: String, newGroup: String?) {
+        withContext(Dispatchers.IO) {
+            val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
+            sources.forEach { source ->
+                source.group?.splitNotBlank(",")?.toHashSet()?.let {
+                    it.remove(oldGroup)
+                    if (!newGroup.isNullOrEmpty())
+                        it.add(newGroup)
+                    source.group = TextUtils.join(",", it)
+                }
+            }
+            appDb.replaceRuleDao.update(*sources.toTypedArray())
+        }
+    }
+
+    suspend fun delGroup(group: String) {
+        withContext(Dispatchers.IO) {
+            val sources = appDb.replaceRuleDao.getByGroup(group)
+            sources.forEach { source ->
+                source.group?.splitNotBlank(",")?.toHashSet()?.let {
+                    it.remove(group)
+                    source.group = TextUtils.join(",", it)
+                }
+            }
+            appDb.replaceRuleDao.update(*sources.toTypedArray())
+        }
+    }
+
+    suspend fun enableByIds(ids: Set<Long>) =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) return@withContext
+
+            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val updated = rules.map { it.copy(isEnabled = true) }
+            appDb.replaceRuleDao.update(*updated.toTypedArray())
+        }
+
+    suspend fun disableByIds(ids: Set<Long>) =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) return@withContext
+
+            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val updated = rules.map { it.copy(isEnabled = false) }
+            appDb.replaceRuleDao.update(*updated.toTypedArray())
+        }
+
+    suspend fun deleteByIds(ids: Set<Long>) =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) return@withContext
+
+            val rules = appDb.replaceRuleDao.getByIds(ids)
+            appDb.replaceRuleDao.delete(*rules.toTypedArray())
+        }
+
+    suspend fun topByIds(ids: Set<Long>) =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) return@withContext
+
+            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val updated = rules.map { it.copy(order = -1) }
+            appDb.replaceRuleDao.update(*updated.toTypedArray())
+        }
+
+    suspend fun bottomByIds(ids: Set<Long>) =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) return@withContext
+
+            val rules = appDb.replaceRuleDao.getByIds(ids)
+            val updated = rules.map { it.copy(order = -2) }
+            appDb.replaceRuleDao.update(*updated.toTypedArray())
+        }
+
+}

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

@@ -7,6 +7,7 @@ import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+import io.legado.app.ui.replace.ReplaceRuleViewModel
 import io.legado.app.ui.replace.edit.ReplaceEditViewModel
 import org.koin.android.ext.koin.androidApplication
 import org.koin.core.module.dsl.viewModel
@@ -16,6 +17,8 @@ val appModule = module {
 
     viewModel { ReplaceEditViewModel(get(), get(), get()) }
 
+    viewModel { ReplaceRuleViewModel(androidApplication()) }
+
     // ReadRecord
     single { get<AppDatabase>().readRecordDao }
     single { get<AppDatabase>().bookDao }

+ 0 - 144
app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt

@@ -1,144 +0,0 @@
-package io.legado.app.ui.replace
-
-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.applyTint
-import io.legado.app.utils.requestInputMethod
-import io.legado.app.utils.setLayout
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.launch
-
-
-class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
-    Toolbar.OnMenuItemClickListener {
-
-    private val viewModel: ReplaceRuleViewModel 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)
-        initView()
-        initData()
-    }
-
-    private fun initView() = binding.run {
-        toolBar.title = getString(R.string.group_manage)
-        toolBar.inflateMenu(R.menu.group_manage)
-        toolBar.menu.applyTint(requireContext())
-        toolBar.setOnMenuItemClickListener(this@GroupManageDialog)
-        recyclerView.layoutManager = LinearLayoutManager(requireContext())
-        recyclerView.addItemDecoration(VerticalDivider(requireContext()))
-        recyclerView.adapter = adapter
-    }
-
-    private fun initData() {
-        lifecycleScope.launch {
-            appDb.replaceRuleDao.flowGroups().conflate().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) }
-                }
-            }
-        }
-    }
-
-}

+ 8 - 351
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt

@@ -1,362 +1,19 @@
 package io.legado.app.ui.replace
 
-import android.annotation.SuppressLint
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MotionEvent
-import android.view.SubMenu
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.databinding.ActivityReplaceRuleBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.help.book.ContentProcessor
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.ui.association.ImportReplaceRuleDialog
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.replace.edit.ReplaceEditActivity
-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.GSON
-import io.legado.app.utils.hideSoftInput
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
-import io.legado.app.utils.readText
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.shouldHideSoftInput
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.splitNotBlank
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.transaction
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import io.legado.app.base.AppTheme
 
-/**
- * 替换规则管理
- */
-class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRuleViewModel>(),
-    SearchView.OnQueryTextListener,
-    PopupMenu.OnMenuItemClickListener,
-    SelectActionBar.CallBack,
-    ReplaceRuleAdapter.CallBack {
-    override val binding by viewBinding(ActivityReplaceRuleBinding::inflate)
-    override val viewModel by viewModels<ReplaceRuleViewModel>()
-    private val importRecordKey = "replaceRuleRecordKey"
-    private val adapter by lazy { ReplaceRuleAdapter(this, this) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private var groups = arrayListOf<String>()
-    private var groupMenu: SubMenu? = null
-    private var searchKey: String? = null
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(
-            ImportReplaceRuleDialog(it)
-        )
-    }
-    private val editActivity =
-        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
-            if (it.resultCode == RESULT_OK) {
-                setResult(RESULT_OK)
-            }
-        }
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        kotlin.runCatching {
-            it.uri?.readText(this)?.let {
-                showDialogFragment(
-                    ImportReplaceRuleDialog(it)
-                )
-            }
-        }.onFailure {
-            toastOnUi("readTextError:${it.localizedMessage}")
-        }
-    }
-    private val exportResult = 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())
-                }
-            }
-        }
-    }
+class ReplaceRuleActivity : AppCompatActivity() {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        initSelectActionView()
-        observeReplaceRuleData()
-        observeGroupData()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.replace_rule, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        groupMenu = menu.findItem(R.id.menu_group)?.subMenu
-        upGroupMenu()
-
-        // 标记当前选中的排序方式
-        when (viewModel.sortMode.value) {
-            "asc" -> menu.findItem(R.id.sort_order_asc)?.isChecked = true
-            "desc" -> menu.findItem(R.id.sort_order_desc)?.isChecked = true
-            "name_asc" -> menu.findItem(R.id.sort_name_asc)?.isChecked = true
-            "name_desc" -> menu.findItem(R.id.sort_name_desc)?.isChecked = true
-        }
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-
-    private fun initRecyclerView() {
-        //binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.layoutManager = LinearLayoutManager(this)
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.addItemDecoration(VerticalDivider(this))
-        val itemTouchCallback = ItemTouchCallback(adapter)
-        itemTouchCallback.isCanDrag = true
-        val dragSelectTouchHelper: DragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        // When this page is opened, it is in selection mode
-        dragSelectTouchHelper.activeSlideSelect()
-
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initSearchView() {
-        //searchView.applyTint(primaryTextColor)
-        searchView.queryHint = getString(R.string.replace_purify_search)
-        searchView.setOnQueryTextListener(this)
-    }
-
-    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.delSelection(adapter.selection) }
-            noButton()
-        }
-    }
-
-    private fun initSelectActionView() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-    }
-
-    private fun observeReplaceRuleData() {
-        lifecycleScope.launch {
-            viewModel.rulesFlow.collectLatest { rules ->
-                adapter.setItems(rules, adapter.diffItemCallBack)
-            }
-        }
-    }
-
-    private fun observeGroupData() {
-        lifecycleScope.launch {
-            appDb.replaceRuleDao.flowGroups().collect {
-                groups.clear()
-                groups.addAll(it)
-                upGroupMenu()
+        setContent {
+            AppTheme {
+                ReplaceRuleScreen(onBackClick = { finish() })
             }
         }
     }
 
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add_replace_rule ->
-                editActivity.launch(ReplaceEditActivity.startIntent(this))
-            R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
-            R.id.menu_del_selection -> viewModel.delSelection(adapter.selection)
-            R.id.menu_import_onLine -> showImportDialog()
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-            R.id.menu_import_qr -> qrCodeResult.launch()
-            R.id.menu_help -> showHelp("replaceRuleHelp")
-            R.id.menu_group_null -> {
-                searchView.setQuery(getString(R.string.no_group), true)
-            }
-            R.id.sort_order_asc -> {
-                viewModel.setSortMode("asc")
-                item.isChecked = true
-            }
-            R.id.sort_order_desc -> {
-                viewModel.setSortMode("desc")
-                item.isChecked = true
-            }
-            R.id.sort_name_asc -> {
-                viewModel.setSortMode("name_asc")
-                item.isChecked = true
-            }
-            R.id.sort_name_desc -> {
-                viewModel.setSortMode("name_desc")
-                item.isChecked = true
-            }
-            else -> if (item.groupId == R.id.replace_group) {
-                searchView.setQuery("group:${item.title}", true)
-                viewModel.setSearchKey("group:${item.title}")
-            }
-        }
-        observeReplaceRuleData()
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    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_top_sel -> viewModel.topSelect(adapter.selection)
-            R.id.menu_bottom_sel -> viewModel.bottomSelect(adapter.selection)
-            R.id.menu_export_selection -> exportResult.launch {
-                mode = HandleFileContract.EXPORT
-                fileData = HandleFileContract.FileData(
-                    "exportReplaceRule.json",
-                    GSON.toJson(adapter.selection).toByteArray(),
-                    "application/json"
-                )
-            }
-        }
-        return false
-    }
-
-    private fun upGroupMenu() = groupMenu?.transaction { menu ->
-        menu.removeGroup(R.id.replace_group)
-        groups.forEach {
-            menu.add(R.id.replace_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(
-                        ImportReplaceRuleDialog(it)
-                    )
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    override fun onQueryTextChange(newText: String?): Boolean {
-        searchKey = newText
-        viewModel.setSearchKey(newText)
-        return false
-    }
-
-    override fun onQueryTextSubmit(query: String?): Boolean {
-        return false
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        Coroutine.async { ContentProcessor.upReplaceRules() }
-    }
-
-    override fun upCountView() {
-        binding.selectActionBar.upCountView(
-            adapter.selection.size,
-            adapter.itemCount
-        )
-    }
-
-    override fun update(vararg rule: ReplaceRule) {
-        setResult(RESULT_OK)
-        viewModel.update(*rule)
-    }
-
-    override fun delete(rule: ReplaceRule) {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n" + rule.name)
-            noButton()
-            yesButton {
-                setResult(RESULT_OK)
-                viewModel.delete(rule)
-            }
-        }
-    }
-
-    override fun edit(rule: ReplaceRule) {
-        setResult(RESULT_OK)
-        editActivity.launch(ReplaceEditActivity.startIntent(this, rule.id))
-    }
-
-    override fun toTop(rule: ReplaceRule) {
-        setResult(RESULT_OK)
-        viewModel.toTop(rule)
-    }
-
-    override fun toBottom(rule: ReplaceRule) {
-        setResult(RESULT_OK)
-        viewModel.toBottom(rule)
-    }
-
-    override fun upOrder() {
-        setResult(RESULT_OK)
-        viewModel.upOrder()
-    }
-}
+}

+ 703 - 0
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -0,0 +1,703 @@
+package io.legado.app.ui.replace
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.gson.Gson
+import io.legado.app.R
+import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.ui.replace.edit.ReplaceEditActivity
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.AnimatedText
+import io.legado.app.ui.widget.components.SearchBarSection
+import io.legado.app.ui.widget.components.SelectionBottomBar
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun ReplaceRuleScreen(
+    viewModel: ReplaceRuleViewModel = koinViewModel(),
+    onBackClick: () -> Unit
+) {
+    //TODO: 期望换为Navigation
+    val context = LocalContext.current
+    val haptic = LocalHapticFeedback.current
+    val scope = rememberCoroutineScope()
+    val listState = rememberLazyListState()
+
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val rules = uiState.rules
+    val groups = uiState.groups
+
+    var isSearch by remember { mutableStateOf(false) }
+    var showMenu by remember { mutableStateOf(false) }
+    var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
+    var showDeleteSelectedDialog by remember { mutableStateOf(false) }
+
+    val sheetState = rememberModalBottomSheetState()
+    var showGroupManageSheet by remember { mutableStateOf(false) }
+
+    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+    val selectedRuleIds by viewModel.selectedRuleIds.collectAsState()
+    val inSelectionMode = selectedRuleIds.isNotEmpty()
+
+    var selectedTabIndex by remember { mutableIntStateOf(0) }
+    val tabItems = listOf(stringResource(R.string.all)) + groups
+
+    val importDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocument(),
+        onResult = { uri ->
+            uri?.let {
+                context.contentResolver.openInputStream(it)?.use { stream ->
+                    val text = stream.reader().readText()
+                    // show import dialog
+                }
+            }
+        }
+    )
+
+    val exportDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.CreateDocument("application/json"),
+        onResult = { uri ->
+            uri?.let {
+                scope.launch {
+                    val rulesToExport = rules
+                        .filter { selectedRuleIds.contains(it.id) }
+                        .map { it.rule }
+
+                    val json = Gson().toJson(rulesToExport)
+                    context.contentResolver.openOutputStream(it)?.use { stream ->
+                        stream.writer().write(json)
+                    }
+                }
+            }
+        }
+    )
+
+    if (showGroupManageSheet) {
+        GroupManageBottomSheet(
+            groups = groups,
+            onDismissRequest = { showGroupManageSheet = false },
+            sheetState = sheetState,
+            viewModel = viewModel
+        )
+    }
+
+    if (showDeleteRuleDialog != null) {
+        AlertDialog(
+            onDismissRequest = { showDeleteRuleDialog = null },
+            title = { Text(stringResource(R.string.delete)) },
+            text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
+            confirmButton = {
+                OutlinedButton(
+                    onClick = {
+                        viewModel.delete(showDeleteRuleDialog!!)
+                        showDeleteRuleDialog = null
+                    },
+                    colors = ButtonDefaults.outlinedButtonColors(
+                        contentColor = MaterialTheme.colorScheme.error,
+                        containerColor = Color.Transparent,
+                    ),
+                ) {
+                    Text(stringResource(R.string.ok))
+                }
+            },
+            dismissButton = {
+                TextButton(onClick = { showDeleteRuleDialog = null }) {
+                    Text(stringResource(R.string.cancel))
+                }
+            }
+        )
+    }
+
+    if (showDeleteSelectedDialog) {
+        AlertDialog(
+            onDismissRequest = { showDeleteSelectedDialog = false },
+            title = { Text(stringResource(R.string.delete)) },
+            text = { Text(stringResource(R.string.del_msg)) },
+            confirmButton = {
+                OutlinedButton(
+                    onClick = {
+                        viewModel.delSelectionByIds(selectedRuleIds)
+                        viewModel.setSelection(emptySet())
+                        showDeleteSelectedDialog = false
+                    },
+                    colors = ButtonDefaults.outlinedButtonColors(
+                        contentColor = MaterialTheme.colorScheme.error,
+                        containerColor = Color.Transparent,
+                    ),
+                ) {
+                    Text(stringResource(R.string.ok))
+                }
+            },
+            dismissButton = {
+                TextButton(onClick = { showDeleteSelectedDialog = false }) {
+                    Text(stringResource(R.string.cancel))
+                }
+            }
+        )
+    }
+
+    Scaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                MediumTopAppBar(
+                    title = {
+                        AnimatedText(
+                            text = if (inSelectionMode) {
+                                stringResource(
+                                    R.string.select_count,
+                                    selectedRuleIds.size,
+                                    rules.size
+                                )
+                            } else {
+                                stringResource(R.string.replace_rule)
+                            }
+                        )
+                    },
+                    navigationIcon = {
+                        IconButton(
+                            onClick = {
+                                if (inSelectionMode) {
+                                    viewModel.setSelection(emptySet())
+                                } else {
+                                    onBackClick()
+                                }
+                            }
+                        ) {
+                            Icon(
+                                imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
+                                contentDescription = if (inSelectionMode) "Cancel" else "Back"
+                            )
+                        }
+                    },
+                    actions = {
+                        if (!inSelectionMode) {
+                            IconButton(onClick = { isSearch = !isSearch }) {
+                                Icon(Icons.Default.Search, contentDescription = "Search")
+                            }
+                            IconButton(onClick = { showMenu = !showMenu }) {
+                                Icon(Icons.Default.MoreVert, contentDescription = "More")
+                            }
+                            DropdownMenu(
+                                expanded = showMenu,
+                                onDismissRequest = { showMenu = false }
+                            ) {
+                                DropdownMenuItem(
+                                    text = { Text("在线导入") },
+                                    onClick = { /*TODO*/ showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("本地导入") },
+                                    onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("分组管理") },
+                                    onClick = { showGroupManageSheet = true; showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("帮助") },
+                                    onClick = { /*TODO*/ showMenu = false }
+                                )
+                                HorizontalDivider()
+                                DropdownMenuItem(
+                                    text = { Text("旧的在前") },
+                                    onClick = { viewModel.setSortMode("asc"); showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("新的在前") },
+                                    onClick = { viewModel.setSortMode("desc"); showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("名称升序") },
+                                    onClick = { viewModel.setSortMode("name_asc"); showMenu = false }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("名称降序") },
+                                    onClick = { viewModel.setSortMode("name_desc"); showMenu = false }
+                                )
+                            }
+                        }
+                    },
+                    scrollBehavior = scrollBehavior
+                )
+                AnimatedVisibility(visible = isSearch && !inSelectionMode) {
+                    SearchBarSection(
+                        query = uiState.searchKey ?: "",
+                        onQueryChange = {
+                            viewModel.setSearchKey(it)
+                            selectedTabIndex = 0
+                        },
+                        placeholder = stringResource(id = R.string.replace_purify_search)
+                    )
+                }
+                AnimatedVisibility(visible = !inSelectionMode) {
+                    val allString = stringResource(R.string.all)
+                    PrimaryScrollableTabRow(
+                        selectedTabIndex = selectedTabIndex,
+                        edgePadding = 0.dp,
+                        divider = {},
+                    ) {
+                        tabItems.forEachIndexed { index, title ->
+                            Tab(
+                                selected = selectedTabIndex == index,
+                                onClick = {
+                                    selectedTabIndex = index
+                                    val group = tabItems.getOrNull(index)
+                                    if (group == allString) {
+                                        viewModel.setSearchKey("")
+                                    } else if (group != null) {
+                                        viewModel.setSearchKey("group:$group")
+                                    }
+                                },
+                                modifier = Modifier.wrapContentWidth(),
+                                text = {
+                                    Text(
+                                        text = title,
+                                        maxLines = 1,
+                                        overflow = TextOverflow.Ellipsis,
+                                        modifier = Modifier.padding(horizontal = 12.dp),
+                                        color = MaterialTheme.colorScheme.onSurfaceVariant
+                                    )
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+        },
+        floatingActionButton = {
+            AnimatedVisibility(
+                visible = !inSelectionMode,
+                enter = fadeIn() + scaleIn(),
+                exit = fadeOut() + scaleOut()
+            ) {
+                FloatingActionButton(onClick = {
+                    context.startActivity(ReplaceEditActivity.startIntent(context))
+                }) {
+                    Icon(Icons.Default.Add, contentDescription = "Add Rule")
+                }
+            }
+        }
+    ) { padding ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+        ) {
+            LazyColumn(
+                state = listState,
+                modifier = Modifier
+                    .padding(padding)
+                    .fillMaxSize(),
+                contentPadding = PaddingValues(
+                    start = 12.dp,
+                    end = 12.dp,
+                    top = 8.dp,
+                    bottom = 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(rules, key = { it.id }) { ui ->
+                    val isSelected = selectedRuleIds.contains(ui.id)
+
+                    ReplaceRuleItem(
+                        modifier = Modifier.animateItem(),
+                        name = ui.name,
+                        isEnabled = ui.isEnabled,
+                        isSelected = isSelected,
+                        inSelectionMode = inSelectionMode,
+                        onEnabledChange = { enabled ->
+                            viewModel.update(ui.rule.copy(isEnabled = enabled))
+                        },
+                        onDelete = { showDeleteRuleDialog = ui.rule },
+                        onToTop = { viewModel.toTop(ui.rule) },
+                        onToBottom = { viewModel.toBottom(ui.rule) },
+                        onToggleSelection = {
+                            viewModel.toggleSelection(ui.id)
+                        },
+                        onClickEdit = {
+                            context.startActivity(
+                                ReplaceEditActivity.startIntent(context, ui.id)
+                            )
+                        }
+                    )
+                }
+            }
+            /*if (inSelectionMode) {
+                DraggableSelectionHandler(
+                    listState = listState,
+                    rules = rules,
+                    selectedRuleIds = selectedRuleIds,
+                    onSelectionChange = viewModel::setSelection,
+                    haptic = haptic,
+                    modifier = Modifier
+                        .padding(padding)
+                        .fillMaxHeight()
+                        .width(60.dp)
+                        .align(Alignment.TopStart)
+                )
+            }*/
+            AnimatedVisibility(
+                visible = inSelectionMode,
+                modifier = Modifier
+                    .align(Alignment.BottomCenter)
+                    .padding(bottom = 24.dp),
+                enter = slideInVertically { it } + fadeIn(),
+                exit = slideOutVertically { it } + fadeOut()
+            ) {
+                SelectionBottomBar(
+                    onSelectAll = {
+                        viewModel.setSelection(rules.map { it.id }.toSet())
+                    },
+                    onSelectInvert = {
+                        val allIds = rules.map { it.id }.toSet()
+                        viewModel.setSelection(allIds - selectedRuleIds)
+                    },
+                    primaryAction = ActionItem(
+                        text = stringResource(R.string.delete),
+                        icon = { Icon(Icons.Default.Delete, null) },
+                        onClick = { showDeleteSelectedDialog = true }
+                    ),
+                    secondaryActions = listOf(
+                        ActionItem(
+                            text = stringResource(R.string.enable),
+                            onClick = {
+                                viewModel.enableSelectionByIds(selectedRuleIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        ),
+                        ActionItem(
+                            text = stringResource(R.string.disable_selection),
+                            onClick = {
+                                viewModel.disableSelectionByIds(selectedRuleIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        ),
+                        ActionItem(
+                            text = stringResource(R.string.to_top),
+                            onClick = {
+                                viewModel.topSelectByIds(selectedRuleIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        ),
+                        ActionItem(
+                            text = stringResource(R.string.to_bottom),
+                            onClick = {
+                                viewModel.bottomSelectByIds(selectedRuleIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        ),
+                        ActionItem(
+                            text = stringResource(R.string.export),
+                            onClick = { exportDoc.launch("exportReplaceRule.json") }
+                        )
+                    )
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun DraggableSelectionHandler(
+    listState: LazyListState,
+    rules: List<ReplaceRuleItemUi>,
+    selectedRuleIds: Set<Long>,
+    onSelectionChange: (Set<Long>) -> Unit,
+    haptic: HapticFeedback,
+    modifier: Modifier = Modifier
+) {
+    var isAddingMode by remember { mutableStateOf(true) }
+    var lastProcessedIndex by remember { mutableIntStateOf(-1) }
+
+    fun findRuleAtOffset(offsetY: Float): Pair<Int, ReplaceRuleItemUi>? {
+        val visibleItem = listState.layoutInfo.visibleItemsInfo.find { item ->
+            offsetY >= item.offset && offsetY <= item.offset + item.size
+        }
+        return visibleItem?.let { item ->
+            rules.getOrNull(item.index)?.let { rule ->
+                item.index to rule
+            }
+        }
+    }
+
+    fun applySelection(id: Long, add: Boolean) {
+        onSelectionChange(
+            if (add) selectedRuleIds + id
+            else selectedRuleIds - id
+        )
+    }
+
+    Box(
+        modifier = modifier.pointerInput(Unit) {
+            coroutineScope {
+                launch {
+                    detectTapGestures(
+                        onTap = { offset ->
+                            val result = findRuleAtOffset(offset.y)
+                            if (result != null) {
+                                val (_, rule) = result
+                                val id = rule.id
+                                onSelectionChange(
+                                    if (selectedRuleIds.contains(id))
+                                        selectedRuleIds - id
+                                    else
+                                        selectedRuleIds + id
+                                )
+                                haptic.performHapticFeedback(
+                                    HapticFeedbackType.TextHandleMove
+                                )
+                            }
+                        }
+                    )
+                }
+
+                launch {
+                    detectDragGestures(
+                        onDragStart = { offset ->
+                            val result = findRuleAtOffset(offset.y)
+                            if (result != null) {
+                                val (index, rule) = result
+                                lastProcessedIndex = index
+
+                                val id = rule.id
+                                isAddingMode = !selectedRuleIds.contains(id)
+                                applySelection(id, isAddingMode)
+
+                                haptic.performHapticFeedback(
+                                    HapticFeedbackType.LongPress
+                                )
+                            }
+                        },
+                        onDrag = { change, _ ->
+                            val result = findRuleAtOffset(change.position.y)
+                            if (result != null) {
+                                val (index, rule) = result
+                                if (index != lastProcessedIndex) {
+                                    lastProcessedIndex = index
+                                    applySelection(rule.id, isAddingMode)
+                                    haptic.performHapticFeedback(
+                                        HapticFeedbackType.TextHandleMove
+                                    )
+                                }
+                            }
+                        },
+                        onDragEnd = {
+                            lastProcessedIndex = -1
+                        },
+                        onDragCancel = {
+                            lastProcessedIndex = -1
+                        }
+                    )
+                }
+            }
+        }
+    )
+}
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun GroupManageBottomSheet(
+    groups: List<String>,
+    sheetState: SheetState,
+    onDismissRequest: () -> Unit,
+    viewModel: ReplaceRuleViewModel
+) {
+    var editingGroup by remember { mutableStateOf<String?>(null) }
+    var updatedGroupName by remember { mutableStateOf("") }
+
+    ModalBottomSheet(
+        onDismissRequest = onDismissRequest,
+        sheetState = sheetState
+    ) {
+        Column(
+            modifier = Modifier.padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            Text(stringResource(R.string.group_manage), style = MaterialTheme.typography.titleLarge)
+            LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                items(groups) { group ->
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        verticalAlignment = Alignment.CenterVertically,
+                        horizontalArrangement = Arrangement.SpaceBetween
+                    ) {
+                        if (editingGroup == group) {
+                            OutlinedTextField(
+                                value = updatedGroupName,
+                                onValueChange = { updatedGroupName = it },
+                                modifier = Modifier.weight(1f)
+                            )
+                            IconButton(onClick = {
+                                viewModel.upGroup(group, updatedGroupName)
+                                editingGroup = null
+                            }) {
+                                Icon(Icons.Default.Check, contentDescription = stringResource(id = R.string.ok))
+                            }
+                        } else {
+                            Text(group, modifier = Modifier.weight(1f))
+                            Row {
+                                IconButton(onClick = {
+                                    editingGroup = group
+                                    updatedGroupName = group
+                                }) {
+                                    Icon(Icons.Default.Edit, contentDescription = stringResource(id = R.string.edit))
+                                }
+                                IconButton(onClick = { viewModel.delGroup(group) }) {
+                                    Icon(Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ReplaceRuleItem(
+    name: String,
+    isEnabled: Boolean,
+    isSelected: Boolean,
+    inSelectionMode: Boolean,
+    onToggleSelection: () -> Unit,
+    onEnabledChange: (Boolean) -> Unit,
+    onDelete: () -> Unit,
+    onToTop: () -> Unit,
+    onToBottom: () -> Unit,
+    onClickEdit: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var showRuleMenu by remember { mutableStateOf(false) }
+    Card(
+        modifier = modifier
+            .fillMaxWidth()
+            .combinedClickable(
+                onClick = { onToggleSelection() },
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null
+            ),
+        shape = MaterialTheme.shapes.medium,
+        colors = CardDefaults.cardColors(
+            containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow
+        )
+    ) {
+        ListItem(
+            modifier = Modifier.animateContentSize(),
+
+            headlineContent = {
+                AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
+                    Text(
+                        text = targetName,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            },
+            leadingContent = {
+                AnimatedContent(
+                    targetState = inSelectionMode,
+                    label = "LeadingCheckbox"
+                ) { visible ->
+                    if (visible) {
+                        Checkbox(
+                            checked = isSelected,
+                            onCheckedChange = null
+                        )
+                    } else {
+                        Spacer(modifier = Modifier.width(0.dp))
+                    }
+                }
+            },
+            trailingContent = {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    Switch(
+                        checked = isEnabled,
+                        onCheckedChange = onEnabledChange
+                    )
+                    IconButton(onClick = onClickEdit) {
+                        Icon(Icons.Default.Edit, contentDescription = "Edit")
+                    }
+                    Box {
+                        IconButton(onClick = { showRuleMenu = true }) {
+                            Icon(Icons.Default.MoreVert, contentDescription = "More Actions")
+                        }
+                        DropdownMenu(
+                            expanded = showRuleMenu,
+                            onDismissRequest = { showRuleMenu = false }
+                        ) {
+                            DropdownMenuItem(
+                                text = { Text("删除") },
+                                onClick = {
+                                    onDelete()
+                                    showRuleMenu = false
+                                }
+                            )
+                            DropdownMenuItem(
+                                text = { Text("置顶") },
+                                onClick = {
+                                    onToTop()
+                                    showRuleMenu = false
+                                }
+                            )
+                            DropdownMenuItem(
+                                text = { Text("置底") },
+                                onClick = {
+                                    onToBottom()
+                                    showRuleMenu = false
+                                }
+                            )
+                        }
+                    }
+                }
+            },
+            colors = ListItemDefaults.colors(
+                containerColor = Color.Transparent
+            )
+        )
+    }
+}

+ 149 - 125
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt

@@ -1,228 +1,252 @@
 package io.legado.app.ui.replace
 
 import android.app.Application
-import android.text.TextUtils
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.PreferKey
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.putPrefString
-import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
 import splitties.init.appCtx
 
+@Immutable
+data class ReplaceRuleItemUi(
+    val id: Long,
+    val name: String,
+    val isEnabled: Boolean,
+    val group: String?,
+    val rule: ReplaceRule
+)
+
+data class ReplaceRuleUiState(
+    val sortMode: String = "desc",
+    val searchKey: String? = null,
+    val groups: List<String> = emptyList(),
+    val rules: List<ReplaceRuleItemUi> = emptyList(),
+    val isLoading: Boolean = false
+)
+
 /**
  * 替换规则数据修改
  * 修改数据要copy,直接修改会导致界面不刷新
  */
 class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
 
+    private val repository = ReplaceRuleRepository()
     private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
     private val _searchKey = MutableStateFlow<String?>(null)
 
-    val sortMode: StateFlow<String> = _sortMode
-    val searchKey: StateFlow<String?> = _searchKey
-
-    fun setSortMode(mode: String) {
-        _sortMode.value = mode
-        context.putPrefString(PreferKey.replaceSortMode, mode)
-    }
+    private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
+    val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds
 
-    fun setSearchKey(key: String?) {
-        _searchKey.value = key
+    fun toggleSelection(id: Long) {
+        _selectedRuleIds.update {
+            if (it.contains(id)) it - id else it + id
+        }
     }
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
+    private val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
         Pair(search, sort)
     }.flatMapLatest { (searchKey, sortMode) ->
         // 先获取基础数据
         val baseFlow = when {
-            searchKey.isNullOrEmpty() -> appDb.replaceRuleDao.flowAll()
-            searchKey == appCtx.getString(R.string.no_group) -> appDb.replaceRuleDao.flowNoGroup()
+            searchKey.isNullOrEmpty() -> repository.flowAll()
+            searchKey == appCtx.getString(R.string.no_group) -> repository.flowNoGroup()
             searchKey.startsWith("group:") -> {
                 val key = searchKey.substringAfter("group:")
-                appDb.replaceRuleDao.flowGroupSearch("%$key%")
+                repository.flowGroupSearch("%$key%")
             }
-            else -> appDb.replaceRuleDao.flowSearch("%$searchKey%")
-        }
-
-        baseFlow.map { rules ->
-            val comparator = when (sortMode) {
-                "asc" -> Comparator<ReplaceRule> { a, b ->
-                    when {
-                        // 置顶优先
-                        a.order == -1 && b.order != -1 -> -1
-                        a.order != -1 && b.order == -1 -> 1
-                        // 置底最后
-                        a.order == -2 && b.order != -2 -> 1
-                        a.order != -2 && b.order == -2 -> -1
-                        // 普通规则按order排序
-                        else -> a.order.compareTo(b.order)
-                    }
-                }
-                "desc" -> Comparator<ReplaceRule> { a, b ->
-                    when {
-                        a.order == -1 && b.order != -1 -> -1
-                        a.order != -1 && b.order == -1 -> 1
-                        a.order == -2 && b.order != -2 -> 1
-                        a.order != -2 && b.order == -2 -> -1
-                        else -> b.order.compareTo(a.order)
-                    }
-                }
-                "name_asc" -> Comparator<ReplaceRule> { a, b ->
-                    when {
-                        a.order == -1 && b.order != -1 -> -1
-                        a.order != -1 && b.order == -1 -> 1
-                        a.order == -2 && b.order != -2 -> 1
-                        a.order != -2 && b.order == -2 -> -1
-                        else -> a.name.lowercase().compareTo(b.name.lowercase())
+            else -> repository.flowSearch("%$searchKey%")
+        }
+
+        baseFlow
+            .map { rules ->
+                val comparator = when (sortMode) {
+                    "asc" -> compareBy<ReplaceRule> {
+                        when (it.order) {
+                            -1 -> Long.MIN_VALUE
+                            -2 -> Long.MAX_VALUE
+                            else -> it.order.toLong()
+                        }
                     }
-                }
-                "name_desc" -> Comparator<ReplaceRule> { a, b ->
-                    when {
-                        a.order == -1 && b.order != -1 -> -1
-                        a.order != -1 && b.order == -1 -> 1
-                        a.order == -2 && b.order != -2 -> 1
-                        a.order != -2 && b.order == -2 -> -1
-                        else -> b.name.lowercase().compareTo(a.name.lowercase())
+                    "desc" -> compareByDescending<ReplaceRule> {
+                        when (it.order) {
+                            -1 -> Long.MAX_VALUE
+                            -2 -> Long.MIN_VALUE
+                            else -> it.order.toLong()
+                        }
                     }
+                    "name_asc" -> compareBy<ReplaceRule> {
+                        when (it.order) {
+                            -1 -> Long.MIN_VALUE
+                            -2 -> Long.MAX_VALUE
+                            else -> 0
+                        }
+                    }.thenBy { it.name.lowercase() }
+                    "name_desc" -> compareBy<ReplaceRule> {
+                        when (it.order) {
+                            -1 -> Long.MIN_VALUE
+                            -2 -> Long.MAX_VALUE
+                            else -> 0
+                        }
+                    }.thenByDescending { it.name.lowercase() }
+                    else -> null
                 }
-                else -> null
+
+                if (comparator != null) rules.sortedWith(comparator) else rules
             }
+    }.flowOn(Dispatchers.Default)
 
-            if (comparator != null) rules.sortedWith(comparator) else rules
+    private val ruleUiFlow: Flow<List<ReplaceRuleItemUi>> =
+        rulesFlow.map { rules ->
+            rules.map { rule ->
+                ReplaceRuleItemUi(
+                    id = rule.id,
+                    name = rule.name,
+                    isEnabled = rule.isEnabled,
+                    group = rule.group,
+                    rule = rule
+                )
+            }
         }
-    }.flowOn(Dispatchers.Default)
+
+    val uiState: StateFlow<ReplaceRuleUiState> = combine(
+        _sortMode,
+        _searchKey,
+        repository.flowGroups(),
+        ruleUiFlow
+    ) { sortMode, searchKey, groups, rules ->
+        ReplaceRuleUiState(
+            sortMode = sortMode,
+            searchKey = searchKey,
+            groups = groups,
+            rules = rules,
+            isLoading = false
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5000),
+        initialValue = ReplaceRuleUiState(isLoading = true)
+    )
+
+    fun setSortMode(mode: String) {
+        _sortMode.value = mode
+        context.putPrefString(PreferKey.replaceSortMode, mode)
+    }
+
+    fun setSearchKey(key: String?) {
+        _searchKey.value = key
+    }
+
+    fun setSelection(ids: Set<Long>) {
+        _selectedRuleIds.value = ids
+    }
 
     fun update(vararg rule: ReplaceRule) {
         execute {
-            appDb.replaceRuleDao.update(*rule)
+            repository.update(*rule)
         }
     }
 
     fun delete(rule: ReplaceRule) {
         execute {
-            appDb.replaceRuleDao.delete(rule)
+            repository.delete(rule)
         }
     }
 
     fun toTop(rule: ReplaceRule) {
         execute {
-            rule.order = -1
-            appDb.replaceRuleDao.update(rule)
+            repository.toTop(rule)
         }
     }
 
-    fun topSelect(rules: List<ReplaceRule>) {
+    fun toBottom(rule: ReplaceRule) {
         execute {
-            rules.forEach {
-                it.order = -1
-            }
-            appDb.replaceRuleDao.update(*rules.toTypedArray())
+            repository.toBottom(rule)
         }
     }
 
-    fun toBottom(rule: ReplaceRule) {
+    fun upOrder() {
         execute {
-            rule.order = -2
-            appDb.replaceRuleDao.update(rule)
+            repository.upOrder()
         }
     }
 
-    fun bottomSelect(rules: List<ReplaceRule>) {
+    fun enableSelection(rules: List<ReplaceRule>) {
         execute {
-            rules.forEach {
-                it.order = -2
-            }
-            appDb.replaceRuleDao.update(*rules.toTypedArray())
+            repository.enableSelection(rules)
         }
     }
 
-    fun upOrder() {
+    fun disableSelection(rules: List<ReplaceRule>) {
         execute {
-            // 重置所有非特殊排序的规则
-            val rules = appDb.replaceRuleDao.all
-            var normalOrder = 1
-            rules.forEach { rule ->
-                if (rule.order >= 0) { // 只重置普通排序的规则
-                    rule.order = normalOrder++
-                }
-            }
-            appDb.replaceRuleDao.update(*rules.toTypedArray())
+            repository.disableSelection(rules)
         }
     }
 
-    fun enableSelection(rules: List<ReplaceRule>) {
+    fun enableSelectionByIds(ids: Set<Long>) {
         execute {
-            val array = Array(rules.size) {
-                rules[it].copy(isEnabled = true)
-            }
-            appDb.replaceRuleDao.update(*array)
+            repository.enableByIds(ids)
         }
     }
 
-    fun disableSelection(rules: List<ReplaceRule>) {
+    fun disableSelectionByIds(ids: Set<Long>) {
         execute {
-            val array = Array(rules.size) {
-                rules[it].copy(isEnabled = false)
-            }
-            appDb.replaceRuleDao.update(*array)
+            repository.disableByIds(ids)
         }
     }
 
-    fun delSelection(rules: List<ReplaceRule>) {
+    fun delSelectionByIds(ids: Set<Long>) {
         execute {
-            appDb.replaceRuleDao.delete(*rules.toTypedArray())
+            repository.deleteByIds(ids)
         }
     }
 
+    fun topSelectByIds(ids: Set<Long>) {
+        execute {
+            repository.topByIds(ids)
+        }
+    }
+
+    fun bottomSelectByIds(ids: Set<Long>) {
+        execute {
+            repository.bottomByIds(ids)
+        }
+    }
+
+
     fun addGroup(group: String) {
         execute {
-            val sources = appDb.replaceRuleDao.noGroup
-            sources.forEach { source ->
-                source.group = group
-            }
-            appDb.replaceRuleDao.update(*sources.toTypedArray())
+            repository.addGroup(group)
         }
     }
 
     fun upGroup(oldGroup: String, newGroup: String?) {
         execute {
-            val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
-            sources.forEach { source ->
-                source.group?.splitNotBlank(",")?.toHashSet()?.let {
-                    it.remove(oldGroup)
-                    if (!newGroup.isNullOrEmpty())
-                        it.add(newGroup)
-                    source.group = TextUtils.join(",", it)
-                }
-            }
-            appDb.replaceRuleDao.update(*sources.toTypedArray())
+            repository.upGroup(oldGroup, newGroup)
         }
     }
 
     fun delGroup(group: String) {
         execute {
-            execute {
-                val sources = appDb.replaceRuleDao.getByGroup(group)
-                sources.forEach { source ->
-                    source.group?.splitNotBlank(",")?.toHashSet()?.let {
-                        it.remove(group)
-                        source.group = TextUtils.join(",", it)
-                    }
-                }
-                appDb.replaceRuleDao.update(*sources.toTypedArray())
-            }
+            repository.delGroup(group)
         }
     }
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -306,7 +306,7 @@ fun ManageGroupDialog(
             if (groups.isEmpty()) Text("暂无其他分组")
             else Column(
                 modifier = Modifier.verticalScroll(rememberScrollState()),
-                verticalArrangement = Arrangement.spacedBy(4.dp)
+                verticalArrangement = Arrangement.spacedBy(8.dp)
             ) {
                 groups.forEach { group ->
                     val isSelected = selected[group] ?: false
@@ -319,7 +319,7 @@ fun ManageGroupDialog(
                                 shape = MaterialTheme.shapes.small
                             )
                             .clickable { selected[group] = !isSelected }
-                            .padding(8.dp),
+                            .padding(12.dp),
                         verticalAlignment = Alignment.CenterVertically
                     ) {
                         Checkbox(checked = isSelected, onCheckedChange = null)

+ 111 - 0
app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt

@@ -0,0 +1,111 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.HorizontalFloatingToolbar
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+data class ActionItem(
+    val text: String,
+    val icon: @Composable (() -> Unit)? = null,
+    val onClick: () -> Unit
+)
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SelectionBottomBar(
+    modifier: Modifier = Modifier,
+    onSelectAll: () -> Unit,
+    onSelectInvert: () -> Unit,
+    primaryAction: ActionItem,
+    secondaryActions: List<ActionItem>
+) {
+    var showMenu by remember { mutableStateOf(false) }
+
+    HorizontalFloatingToolbar(
+        modifier = modifier,
+        expanded = true,
+        leadingContent = {
+            IconButton(onClick = onSelectAll) {
+                Icon(
+                    imageVector = Icons.Default.SelectAll,
+                    contentDescription = "Select All"
+                )
+            }
+            IconButton(onClick = onSelectInvert) {
+                Icon(
+                    imageVector = Icons.Default.Refresh,
+                    contentDescription = "Invert Selection"
+                )
+            }
+        },
+        trailingContent = {
+            if (secondaryActions.isNotEmpty()) {
+                Box {
+                    IconButton(onClick = { showMenu = true }) {
+                        Icon(
+                            imageVector = Icons.Default.MoreVert,
+                            contentDescription = "More actions"
+                        )
+                    }
+                    DropdownMenu(
+                        expanded = showMenu,
+                        onDismissRequest = { showMenu = false }
+                    ) {
+                        secondaryActions.forEach { action ->
+                            DropdownMenuItem(
+                                text = { Text(action.text) },
+                                leadingIcon = action.icon,
+                                onClick = {
+                                    action.onClick()
+                                    showMenu = false
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+        },
+        content = {
+            TooltipBox(
+                positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+                    TooltipAnchorPosition.Above
+                ),
+                tooltip = { PlainTooltip { Text(primaryAction.text) } },
+                state = rememberTooltipState(),
+            ) {
+                FilledIconButton(
+                    modifier = Modifier.width(64.dp),
+                    onClick = primaryAction.onClick,
+                ) {
+                    primaryAction.icon?.invoke()
+                }
+            }
+        }
+    )
+}

+ 1 - 1
app/src/main/res/values-zh/strings.xml

@@ -637,7 +637,7 @@
     <string name="auto_dark_mode_s">夜间模式跟随系统</string>
     <string name="go_back">上级</string>
     <string name="tone_colour">在线朗读音色</string>
-    <string name="select_count">(%1$d/%2$d)</string>
+    <string name="select_count">已选 %1$d / %2$d</string>
     <string name="show_rss">显示订阅</string>
     <string name="service_stop">服务已停止</string>
     <string name="service_start">正在启动服务\n具体信息请查看通知栏</string>