Procházet zdrojové kódy

[新增] 由 Compose 重写的字典规则功能

HapeLee před 9 měsíci
rodič
revize
22cfdc508c

+ 19 - 3
app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt

@@ -1,10 +1,14 @@
 package io.legado.app.data.dao
 package io.legado.app.data.dao
 
 
-import androidx.room.*
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.DictRule
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 
 
-
 @Dao
 @Dao
 interface DictRuleDao {
 interface DictRuleDao {
 
 
@@ -17,9 +21,15 @@ interface DictRuleDao {
     @Query("select * from dictRules order by sortNumber")
     @Query("select * from dictRules order by sortNumber")
     fun flowAll(): Flow<List<DictRule>>
     fun flowAll(): Flow<List<DictRule>>
 
 
+    @Query("select * from dictRules where name LIKE '%' || :key || '%' order by sortNumber")
+    fun flowSearch(key: String): Flow<List<DictRule>>
+
     @Query("select * from dictRules where name = :name")
     @Query("select * from dictRules where name = :name")
     fun getByName(name: String): DictRule?
     fun getByName(name: String): DictRule?
 
 
+    @Query("SELECT * FROM dictRules WHERE name IN (:names)")
+    fun getByNames(names: Set<String>): List<DictRule>
+
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insert(vararg dictRule: DictRule)
     fun insert(vararg dictRule: DictRule)
 
 
@@ -29,4 +39,10 @@ interface DictRuleDao {
     @Delete
     @Delete
     fun delete(vararg dictRule: DictRule)
     fun delete(vararg dictRule: DictRule)
 
 
-}
+    @Query("UPDATE dictRules SET enabled = :enabled WHERE name IN (:names)")
+    suspend fun updateEnabled(names: Set<String>, enabled: Boolean)
+
+    @Query("DELETE FROM dictRules WHERE name IN (:names)")
+    suspend fun deleteByIds(names: Set<String>)
+
+}

+ 64 - 0
app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt

@@ -0,0 +1,64 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.DictRule
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class DictRuleRepository {
+
+    private val dao = appDb.dictRuleDao
+
+    fun flowAll(): Flow<List<DictRule>> {
+        return dao.flowAll()
+    }
+
+    fun flowSearch(key: String): Flow<List<DictRule>> {
+        return dao.flowSearch(key)
+    }
+
+    fun getAll(): List<DictRule> {
+        return dao.all
+    }
+
+    fun insert(vararg rule: DictRule) {
+        dao.insert(*rule)
+    }
+
+    fun delete(vararg rule: DictRule) {
+        dao.delete(*rule)
+    }
+
+    fun update(vararg rule: DictRule) {
+        dao.update(*rule)
+    }
+
+    suspend fun enableByIds(names: Set<String>) = withContext(Dispatchers.IO) {
+        if (names.isEmpty()) return@withContext
+        val rules = dao.getByNames(names)
+        val updated = rules.map { it.copy(enabled = true) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun disableByIds(names: Set<String>) = withContext(Dispatchers.IO) {
+        if (names.isEmpty()) return@withContext
+        val rules = dao.getByNames(names)
+        val updated = rules.map { it.copy(enabled = false) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun deleteByIds(names: Set<String>) = withContext(Dispatchers.IO) {
+        if (names.isEmpty()) return@withContext
+        val rules = dao.getByNames(names)
+        dao.delete(*rules.toTypedArray())
+    }
+
+    suspend fun moveOrder(rules: List<DictRule>) = withContext(Dispatchers.IO) {
+        val updatedRules = rules.mapIndexed { index, rule ->
+            rule.copy(sortNumber = index + 1)
+        }
+        dao.update(*updatedRules.toTypedArray())
+    }
+
+}

+ 11 - 161
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt

@@ -1,62 +1,29 @@
 package io.legado.app.ui.dict.rule
 package io.legado.app.ui.dict.rule
 
 
+//import io.legado.app.lib.theme.primaryColor
 import android.annotation.SuppressLint
 import android.annotation.SuppressLint
 import android.os.Bundle
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.compose.runtime.Composable
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.DictRule
-import io.legado.app.databinding.ActivityDictRuleBinding
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.databinding.DialogEditTextBinding
 import io.legado.app.databinding.DialogEditTextBinding
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.primaryColor
 import io.legado.app.ui.association.ImportDictRuleDialog
 import io.legado.app.ui.association.ImportDictRuleDialog
 import io.legado.app.ui.file.HandleFileContract
 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.ui.theme.AppTheme
 import io.legado.app.utils.ACache
 import io.legado.app.utils.ACache
-import io.legado.app.utils.GSON
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
 import io.legado.app.utils.readText
 import io.legado.app.utils.readText
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
 
 
-class DictRuleActivity : VMBaseActivity<ActivityDictRuleBinding, DictRuleViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    SelectActionBar.CallBack,
-    DictRuleAdapter.CallBack {
+class DictRuleActivity : BaseComposeActivity() {
 
 
-    override val viewModel by viewModels<DictRuleViewModel>()
-    override val binding by viewBinding(ActivityDictRuleBinding::inflate)
     private val importRecordKey = "dictRuleUrls"
     private val importRecordKey = "dictRuleUrls"
-    private val adapter by lazy { DictRuleAdapter(this, this) }
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(
-            ImportDictRuleDialog(it)
-        )
-    }
+
     private val importDoc = registerForActivityResult(HandleFileContract()) {
     private val importDoc = registerForActivityResult(HandleFileContract()) {
         kotlin.runCatching {
         kotlin.runCatching {
             it.uri?.readText(this)?.let {
             it.uri?.readText(this)?.let {
@@ -88,134 +55,17 @@ class DictRuleActivity : VMBaseActivity<ActivityDictRuleBinding, DictRuleViewMod
 
 
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSelectActionView()
-        observeDictRuleData()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.dict_rule, menu)
-        return super.onCompatCreateOptionsMenu(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 initSelectActionView() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.dict_rule_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
+        //observeDictRuleData()
     }
     }
 
 
-    private fun observeDictRuleData() {
-        lifecycleScope.launch {
-            appDb.dictRuleDao.flowAll().catch {
-                AppLog.put("字典规则获取数据失败\n${it.localizedMessage}", it)
-            }.flowOn(IO).collect {
-                adapter.setItems(it, adapter.diffItemCallBack)
-            }
+    @Composable
+    override fun Content() {
+        AppTheme {
+            DictRuleScreen(onBackClick = { finish() })
         }
         }
     }
     }
 
 
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add -> showDialogFragment<DictRuleEditDialog>()
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-
-            R.id.menu_import_onLine -> showImportDialog()
-            R.id.menu_import_qr -> qrCodeResult.launch()
-            R.id.menu_import_default -> viewModel.importDefault()
-            R.id.menu_help -> showHelp("dictRuleHelp")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun onMenuItemClick(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_enable_selection -> {
-                viewModel.enableSelection(*adapter.selection.toTypedArray())
-            }
-
-            R.id.menu_disable_selection -> {
-                viewModel.disableSelection(*adapter.selection.toTypedArray())
-            }
-
-            R.id.menu_export_selection -> exportResult.launch {
-                mode = HandleFileContract.EXPORT
-                fileData = HandleFileContract.FileData(
-                    "exportDictRule.json",
-                    GSON.toJson(adapter.selection).toByteArray(),
-                    "application/json"
-                )
-            }
-        }
-        return true
-    }
-
-    override fun onClickSelectBarMainAction() {
-        viewModel.delete(*adapter.selection.toTypedArray())
-    }
-
-    override fun selectAll(selectAll: Boolean) {
-        if (selectAll) {
-            adapter.selectAll()
-        } else {
-            adapter.revertSelection()
-        }
-    }
-
-    override fun revertSelection() {
-        adapter.revertSelection()
-    }
-
-    override fun update(vararg rule: DictRule) {
-        viewModel.update(*rule)
-    }
-
-    override fun delete(rule: DictRule) {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n" + rule.name)
-            noButton()
-            yesButton {
-                viewModel.delete(rule)
-            }
-        }
-    }
-
-    override fun edit(rule: DictRule) {
-        showDialogFragment(DictRuleEditDialog(rule.name))
-    }
-
-    override fun upOrder() {
-        viewModel.upSortNumber()
-    }
-
-    override fun upCountView() {
-        binding.selectActionBar.upCountView(
-            adapter.selection.size,
-            adapter.itemCount
-        )
-    }
-
     @SuppressLint("InflateParams")
     @SuppressLint("InflateParams")
     private fun showImportDialog() {
     private fun showImportDialog() {
         val aCache = ACache.get(cacheDir = false)
         val aCache = ACache.get(cacheDir = false)

+ 0 - 209
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleAdapter.kt

@@ -1,209 +0,0 @@
-package io.legado.app.ui.dict.rule
-
-import android.content.Context
-import android.os.Bundle
-import android.view.ViewGroup
-import androidx.core.os.bundleOf
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.DictRule
-import io.legado.app.databinding.ItemDictRuleBinding
-//import io.legado.app.lib.theme.backgroundColor
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-
-
-class DictRuleAdapter(context: Context, var callBack: CallBack) :
-    RecyclerAdapter<DictRule, ItemDictRuleBinding>(context),
-    ItemTouchCallback.Callback {
-
-    private val selected = linkedSetOf<DictRule>()
-
-    val selection: List<DictRule>
-        get() {
-            return getItems().filter {
-                selected.contains(it)
-            }
-        }
-
-    val diffItemCallBack = object : DiffUtil.ItemCallback<DictRule>() {
-
-        override fun areItemsTheSame(oldItem: DictRule, newItem: DictRule): Boolean {
-            return oldItem.name == newItem.name
-        }
-
-        override fun areContentsTheSame(oldItem: DictRule, newItem: DictRule): Boolean {
-            if (oldItem.name != newItem.name) {
-                return false
-            }
-            if (oldItem.enabled != newItem.enabled) {
-                return false
-            }
-            return true
-        }
-
-        override fun getChangePayload(oldItem: DictRule, newItem: DictRule): Any? {
-            val payload = Bundle()
-            if (oldItem.name != newItem.name) {
-                payload.putBoolean("upName", true)
-            }
-            if (oldItem.enabled != newItem.enabled) {
-                payload.putBoolean("enabled", newItem.enabled)
-            }
-            if (payload.isEmpty) {
-                return null
-            }
-            return payload
-        }
-    }
-
-    fun selectAll() {
-        getItems().forEach {
-            selected.add(it)
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    fun revertSelection() {
-        getItems().forEach {
-            if (selected.contains(it)) {
-                selected.remove(it)
-            } else {
-                selected.add(it)
-            }
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    override fun getViewBinding(parent: ViewGroup): ItemDictRuleBinding {
-        return ItemDictRuleBinding.inflate(inflater, parent, false)
-    }
-
-    override fun onCurrentListChanged() {
-        callBack.upCountView()
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemDictRuleBinding,
-        item: DictRule,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            if (payloads.isEmpty()) {
-                //root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f))
-                cbName.text = item.name
-                swtEnabled.isChecked = item.enabled
-                cbName.isChecked = selected.contains(item)
-            } else {
-                for (i in payloads.indices) {
-                    val bundle = payloads[i] as Bundle
-                    bundle.keySet().forEach {
-                        when (it) {
-                            "selected" -> cbName.isChecked = selected.contains(item)
-                            "upName" -> cbName.text = item.name
-                            "enabled" -> swtEnabled.isChecked = item.enabled
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemDictRuleBinding) {
-        binding.apply {
-            swtEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
-                if (buttonView.isPressed) {
-                    getItem(holder.layoutPosition)?.let {
-                        it.enabled = isChecked
-                        callBack.update(it)
-                    }
-                }
-            }
-            cbName.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    if (cbName.isChecked) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                }
-                callBack.upCountView()
-            }
-            ivEdit.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    callBack.edit(it)
-                }
-            }
-            ivDelete.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    callBack.delete(it)
-                }
-            }
-        }
-    }
-
-    override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
-        val srcItem = getItem(srcPosition)
-        val targetItem = getItem(targetPosition)
-        if (srcItem != null && targetItem != null) {
-            if (srcItem.sortNumber == targetItem.sortNumber) {
-                callBack.upOrder()
-            } else {
-                val srcOrder = srcItem.sortNumber
-                srcItem.sortNumber = targetItem.sortNumber
-                targetItem.sortNumber = srcOrder
-                movedItems.add(srcItem)
-                movedItems.add(targetItem)
-            }
-        }
-        swapItem(srcPosition, targetPosition)
-        return true
-    }
-
-    private val movedItems = linkedSetOf<DictRule>()
-
-    override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
-        if (movedItems.isNotEmpty()) {
-            callBack.update(*movedItems.toTypedArray())
-            movedItems.clear()
-        }
-    }
-
-    val dragSelectCallback: DragSelectTouchHelper.Callback =
-        object : DragSelectTouchHelper.AdvanceCallback<DictRule>(Mode.ToggleAndReverse) {
-            override fun currentSelectedId(): MutableSet<DictRule> {
-                return selected
-            }
-
-            override fun getItemId(position: Int): DictRule {
-                return getItem(position)!!
-            }
-
-            override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
-                getItem(position)?.let {
-                    if (isSelected) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                    notifyItemChanged(position, bundleOf(Pair("selected", null)))
-                    callBack.upCountView()
-                    return true
-                }
-                return false
-            }
-        }
-
-    interface CallBack {
-        fun update(vararg rule: DictRule)
-        fun delete(rule: DictRule)
-        fun edit(rule: DictRule)
-        fun upOrder()
-        fun upCountView()
-    }
-}

+ 0 - 122
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditDialog.kt

@@ -1,122 +0,0 @@
-package io.legado.app.ui.dict.rule
-
-import android.app.Application
-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.viewModels
-import io.legado.app.R
-import io.legado.app.base.BaseBottomSheetDialogFragment
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.DictRule
-import io.legado.app.databinding.DialogDictRuleEditBinding
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.utils.*
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-
-class DictRuleEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_dict_rule_edit),
-    Toolbar.OnMenuItemClickListener {
-
-    val viewModel by viewModels<DictRuleEditViewModel>()
-    val binding by viewBinding(DialogDictRuleEditBinding::bind)
-
-    constructor(name: String) : this() {
-        arguments = Bundle().apply {
-            putString("name", name)
-        }
-    }
-
-    override fun onStart() {
-        super.onStart()
-        setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        //binding.toolBar.setBackgroundColor(primaryColor)
-        binding.toolBar.inflateMenu(R.menu.dict_rule_edit)
-        //binding.toolBar.menu.applyTint(requireContext())
-        binding.toolBar.setOnMenuItemClickListener(this)
-        viewModel.initData(arguments?.getString("name")) {
-            upRuleView(viewModel.dictRule)
-        }
-    }
-
-    override fun onMenuItemClick(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_save -> viewModel.save(getDictRule()) {
-                dismissAllowingStateLoss()
-            }
-            R.id.menu_copy_rule -> viewModel.copyRule(getDictRule())
-            R.id.menu_paste_rule -> viewModel.pasteRule {
-                upRuleView(it)
-            }
-        }
-        return true
-    }
-
-    private fun upRuleView(dictRule: DictRule?) {
-        binding.tvRuleName.setText(dictRule?.name)
-        binding.tvUrlRule.setText(dictRule?.urlRule)
-        binding.tvShowRule.setText(dictRule?.showRule)
-    }
-
-    private fun getDictRule(): DictRule {
-        val dictRule = viewModel.dictRule?.copy() ?: DictRule()
-        dictRule.name = binding.tvRuleName.text.toString()
-        dictRule.urlRule = binding.tvUrlRule.text.toString()
-        dictRule.showRule = binding.tvShowRule.text.toString()
-        return dictRule
-    }
-
-    class DictRuleEditViewModel(application: Application) : BaseViewModel(application) {
-
-        var dictRule: DictRule? = null
-
-        fun initData(name: String?, onFinally: () -> Unit) {
-            execute {
-                if (dictRule == null && name != null) {
-                    dictRule = appDb.dictRuleDao.getByName(name)
-                }
-            }.onFinally {
-                onFinally.invoke()
-            }
-        }
-
-        fun save(newDictRule: DictRule, onFinally: () -> Unit) {
-            execute {
-                dictRule?.let {
-                    appDb.dictRuleDao.delete(it)
-                }
-                appDb.dictRuleDao.insert(newDictRule)
-                dictRule = newDictRule
-            }.onFinally {
-                onFinally.invoke()
-            }
-        }
-
-        fun copyRule(dictRule: DictRule) {
-            context.sendToClip(GSON.toJson(dictRule))
-        }
-
-        fun pasteRule(success: (DictRule) -> Unit) {
-            val text = context.getClipText()
-            if (text.isNullOrBlank()) {
-                context.toastOnUi("剪贴板没有内容")
-                return
-            }
-            execute {
-                GSON.fromJsonObject<DictRule>(text).getOrThrow()
-            }.onSuccess {
-                success.invoke(it)
-            }.onError {
-                context.toastOnUi("格式不对")
-            }
-        }
-
-    }
-
-}

+ 174 - 0
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt

@@ -0,0 +1,174 @@
+package io.legado.app.ui.dict.rule
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.NoteAdd
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.DictRule
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DictRuleEditSheet(
+    rule: DictRule?,
+    onDismissRequest: () -> Unit,
+    onSave: (DictRule) -> Unit,
+    onCopy: (DictRule) -> Unit,
+    onPaste: () -> DictRule?
+) {
+    val scope = rememberCoroutineScope()
+    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+    var name by remember(rule) { mutableStateOf(rule?.name ?: "") }
+    var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") }
+    var showRule by remember(rule) { mutableStateOf(rule?.showRule ?: "") }
+    var showMenu by remember { mutableStateOf(false) }
+
+    ModalBottomSheet(
+        onDismissRequest = onDismissRequest,
+        sheetState = sheetState,
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxWidth()
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .verticalScroll(rememberScrollState())
+            ) {
+                CenterAlignedTopAppBar(
+                    title = { Text(stringResource(R.string.dict_rule)) },
+                    navigationIcon = {
+                        IconButton(onClick = onDismissRequest) {
+                            Icon(
+                                Icons.Default.Close,
+                                contentDescription = stringResource(R.string.cancel)
+                            )
+                        }
+                    },
+                    actions = {
+                        IconButton(onClick = { showMenu = true }) {
+                            Icon(Icons.Default.MoreVert, contentDescription = "More")
+                        }
+                        DropdownMenu(
+                            expanded = showMenu,
+                            onDismissRequest = { showMenu = false }
+                        ) {
+                            DropdownMenuItem(
+                                text = { Text(stringResource(R.string.copy_rule)) },
+                                leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
+                                onClick = {
+                                    onCopy(
+                                        DictRule(
+                                            name,
+                                            urlRule,
+                                            showRule,
+                                            enabled = rule?.enabled ?: true
+                                        )
+                                    )
+                                    showMenu = false
+                                }
+                            )
+                            DropdownMenuItem(
+                                text = { Text(stringResource(R.string.paste_rule)) },
+                                leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
+                                onClick = {
+                                    scope.launch {
+                                        onPaste()?.let {
+                                            name = it.name
+                                            urlRule = it.urlRule
+                                            showRule = it.showRule
+                                        }
+                                    }
+                                    showMenu = false
+                                }
+                            )
+                        }
+                    },
+                    colors = TopAppBarDefaults.topAppBarColors(
+                        containerColor = Color.Transparent
+                    )
+                )
+
+                Column(
+                    modifier = Modifier
+                        .padding(horizontal = 16.dp)
+                        .padding(bottom = 96.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = name,
+                        onValueChange = { name = it },
+                        label = { Text(stringResource(R.string.name)) },
+                        singleLine = true
+                    )
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = urlRule,
+                        onValueChange = { urlRule = it },
+                        label = { Text(stringResource(R.string.url_rule)) }
+                    )
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = showRule,
+                        onValueChange = { showRule = it },
+                        label = { Text(stringResource(R.string.show_rule)) },
+                        minLines = 3
+                    )
+                }
+            }
+
+            FloatingActionButton(
+                onClick = {
+                    onSave(
+                        rule?.copy(name = name, urlRule = urlRule, showRule = showRule)
+                            ?: DictRule(name = name, urlRule = urlRule, showRule = showRule)
+                    )
+                },
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .padding(16.dp),
+                containerColor = MaterialTheme.colorScheme.primaryContainer,
+                contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+            ) {
+                Icon(Icons.Default.Save, contentDescription = "Save")
+            }
+        }
+    }
+}

+ 351 - 0
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -0,0 +1,351 @@
+package io.legado.app.ui.dict.rule
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+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.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingToolbarDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumFlexibleTopAppBar
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.animateFloatingActionButton
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.legado.app.R
+import io.legado.app.data.entities.DictRule
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.AnimatedText
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
+import io.legado.app.ui.widget.components.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.SearchBarSection
+import io.legado.app.ui.widget.components.SelectionBottomBar
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun DictRuleScreen(
+    viewModel: DictRuleViewModel = viewModel(),
+    onBackClick: () -> Unit
+) {
+
+    val uiState by viewModel.uiState.collectAsState()
+    val listState = rememberLazyListState()
+    var isSearch by remember { mutableStateOf(false) }
+    val selectedIds by viewModel.selectedIds.collectAsState()
+    val inSelectionMode = selectedIds.isNotEmpty()
+    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+    val hapticFeedback = LocalHapticFeedback.current
+    var showDeleteSelectedDialog by remember { mutableStateOf(false) }
+    var showEditSheet by remember { mutableStateOf(false) }
+    var editingRule by remember { mutableStateOf<DictRule?>(null) }
+    var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        viewModel.moveItemInList(from.index, to.index)
+        hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
+    }
+
+    LaunchedEffect(reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging) {
+            viewModel.saveSortOrder()
+        }
+    }
+
+    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(selectedIds)
+                        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))
+                }
+            }
+        )
+    }
+
+    if (showEditSheet) {
+        DictRuleEditSheet(
+            rule = editingRule,
+            onDismissRequest = { showEditSheet = false },
+            onSave = {
+                if (editingRule == null) {
+                    viewModel.insert(it)
+                } else {
+                    viewModel.update(it)
+                }
+                showEditSheet = false
+            },
+            onCopy = { viewModel.copyRule(it) },
+            onPaste = { viewModel.pasteRule() }
+        )
+    }
+
+    Scaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                MediumFlexibleTopAppBar(
+                    title = {
+                        val titleText = remember(inSelectionMode, selectedIds, uiState.items) {
+                            when {
+                                inSelectionMode -> "已选择 ${selectedIds.size}/${uiState.items.size}"
+                                else -> "字典规则"
+                            }
+                        }
+                        AnimatedText(
+                            text = titleText
+                        )
+                    },
+                    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")
+                            }
+                        }
+                    },
+                    scrollBehavior = scrollBehavior
+                )
+                AnimatedVisibility(visible = isSearch && !inSelectionMode) {
+                    SearchBarSection(
+                        query = uiState.searchKey ?: "",
+                        onQueryChange = {
+                            viewModel.setSearchKey(it)
+                        },
+                        placeholder = stringResource(id = R.string.search)
+                    )
+                }
+            }
+        },
+        floatingActionButton = {
+            TooltipBox(
+                positionProvider =
+                    TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+                tooltip = { PlainTooltip { Text("Localized description") } },
+                state = rememberTooltipState(),
+            ) {
+                FloatingActionButton(
+                    modifier = Modifier.animateFloatingActionButton(
+                        visible = !inSelectionMode,
+                        alignment = Alignment.BottomEnd,
+                    ),
+                    onClick = {
+                        editingRule = null
+                        showEditSheet = true
+                    }
+                ) {
+                    Icon(Icons.Default.Add, contentDescription = "Add Rule")
+                }
+            }
+        }
+    ) { paddingValues ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(paddingValues)
+        ) {
+            FastScrollLazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = listState,
+                contentPadding = PaddingValues(
+                    top = 8.dp,
+                    bottom = 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(uiState.items, key = { it.name }) { item ->
+                    ReorderableSelectionItem(
+                        state = reorderableState,
+                        key = item.name,
+                        title = item.name,
+                        isEnabled = item.isEnabled,
+                        isSelected = selectedIds.contains(item.name),
+                        inSelectionMode = inSelectionMode,
+                        onToggleSelection = {
+                            viewModel.toggleSelection(item.name)
+                        },
+                        onEnabledChange = { enabled ->
+                            viewModel.update(item.rule.copy(enabled = enabled))
+                        },
+                        onClickEdit = {
+                            editingRule = item.rule
+                            showEditSheet = true
+                        },
+                        modifier = Modifier.padding(horizontal = 12.dp),
+                        trailingAction = {
+                            IconButton(
+                                onClick = {
+                                    showDeleteRuleDialog = item.rule
+                                }
+                            ) {
+                                Icon(Icons.Default.Delete, contentDescription = "Delete")
+                            }
+                        }
+                    )
+                }
+            }
+            if (inSelectionMode) {
+                DraggableSelectionHandler(
+                    listState = listState,
+                    items = uiState.items,
+                    selectedIds = selectedIds,
+                    onSelectionChange = viewModel::setSelection,
+                    idProvider = { it.name },
+                    modifier = Modifier
+                        .fillMaxHeight()
+                        .width(60.dp)
+                        .align(Alignment.TopStart)
+                )
+            }
+            AnimatedVisibility(
+                visible = inSelectionMode,
+                modifier =
+                    Modifier
+                        .align(Alignment.BottomCenter)
+                        .offset(y = -FloatingToolbarDefaults.ScreenOffset)
+                        .zIndex(1f),
+                enter = slideInVertically { it } + fadeIn(),
+                exit = slideOutVertically { it } + fadeOut()
+            ) {
+                SelectionBottomBar(
+                    onSelectAll = {
+                        viewModel.setSelection(uiState.items.map { it.name }.toSet())
+                    },
+                    onSelectInvert = {
+                        val allIds = uiState.items.map { it.name }.toSet()
+                        viewModel.setSelection(allIds - selectedIds)
+                    },
+                    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(selectedIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        ),
+                        ActionItem(
+                            text = stringResource(R.string.disable_selection),
+                            onClick = {
+                                viewModel.disableSelectionByIds(selectedIds)
+                                viewModel.setSelection(emptySet())
+                            }
+                        )
+                    )
+                )
+            }
+
+        }
+    }
+}

+ 172 - 27
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt

@@ -1,57 +1,184 @@
 package io.legado.app.ui.dict.rule
 package io.legado.app.ui.dict.rule
 
 
 import android.app.Application
 import android.app.Application
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.DictRule
+import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.help.DefaultData
 import io.legado.app.help.DefaultData
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getClipText
+import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
+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.asStateFlow
+import kotlinx.coroutines.flow.combine
+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 kotlinx.coroutines.launch
+
+@Immutable
+data class DictRuleItemUi(
+    val name: String,
+    val urlRule: String,
+    val showRule: String,
+    val isEnabled: Boolean,
+    val rule: DictRule
+)
+
+data class DictRuleUiState(
+    val searchKey: String? = null,
+    val items: List<DictRuleItemUi> = emptyList(),
+    val dictRule: List<DictRule> = emptyList(),
+    val selectedIds: Set<String> = emptySet(),
+    val isLoading: Boolean = false
+)
 
 
 class DictRuleViewModel(application: Application) : BaseViewModel(application) {
 class DictRuleViewModel(application: Application) : BaseViewModel(application) {
 
 
+    private val repository = DictRuleRepository()
+    private val _searchKey = MutableStateFlow<String?>(null)
+    private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
+
+    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
+    val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()
+
+    fun toggleSelection(id: String) {
+        _selectedIds.update {
+            if (it.contains(id)) it - id else it + id
+        }
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val rulesFlow = _searchKey.flatMapLatest { searchKey ->
+        val baseFlow = if (searchKey.isNullOrEmpty()) {
+            repository.flowAll()
+        } else {
+            repository.flowSearch("%$searchKey%")
+        }
+
+        baseFlow.map { rules ->
+            rules.sortedBy { it.sortNumber }
+        }
+    }.flowOn(Dispatchers.Default)
+
+    private val ruleUiFlow: Flow<List<DictRuleItemUi>> =
+        rulesFlow.map { rules ->
+            rules.map { rule ->
+                DictRuleItemUi(
+                    name = rule.name,
+                    urlRule = rule.urlRule,
+                    showRule = rule.showRule,
+                    isEnabled = rule.enabled,
+                    rule = rule
+                )
+            }
+        }
+
+    val uiState: StateFlow<DictRuleUiState> = combine(
+        _searchKey,
+        _uiRules,
+        _selectedIds
+    ) { searchKey, rules, selectedIds ->
+        DictRuleUiState(
+            searchKey = searchKey,
+            items = rules,
+            selectedIds = selectedIds,
+            isLoading = false
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5000),
+        initialValue = DictRuleUiState(isLoading = true)
+    )
 
 
-    fun update(vararg dictRule: DictRule) {
+    init {
+        viewModelScope.launch {
+            ruleUiFlow.collect { rules ->
+                _uiRules.value = rules
+            }
+        }
+    }
+
+    fun setSearchKey(key: String?) {
+        _searchKey.value = key
+    }
+
+    fun setSelection(ids: Set<String>) {
+        _selectedIds.value = ids
+    }
+
+
+    fun enableSelectionByIds(ids: Set<String>) {
         execute {
         execute {
-            appDb.dictRuleDao.update(*dictRule)
-        }.onError {
-            val msg = "更新字典规则出错\n${it.localizedMessage}"
-            AppLog.put(msg, it)
-            context.toastOnUi(msg)
+            repository.enableByIds(ids)
         }
         }
     }
     }
 
 
-    fun delete(vararg dictRule: DictRule) {
+    fun disableSelectionByIds(ids: Set<String>) {
         execute {
         execute {
-            appDb.dictRuleDao.delete(*dictRule)
-        }.onError {
-            val msg = "删除字典规则出错\n${it.localizedMessage}"
-            AppLog.put(msg, it)
-            context.toastOnUi(msg)
+            repository.disableByIds(ids)
         }
         }
     }
     }
 
 
-    fun upSortNumber() {
+    fun delSelectionByIds(ids: Set<String>) {
         execute {
         execute {
-            val rules = appDb.dictRuleDao.all
-            for ((index, rule) in rules.withIndex()) {
-                rule.sortNumber = index + 1
-            }
-            appDb.dictRuleDao.insert(*rules.toTypedArray())
+            repository.deleteByIds(ids)
+            _selectedIds.update { it - ids }
+        }
+    }
+
+    fun update(vararg rule: DictRule) {
+        execute {
+            repository.update(*rule)
+        }
+    }
+
+    fun insert(vararg rule: DictRule) {
+        execute {
+            repository.insert(*rule)
         }
         }
     }
     }
 
 
-    fun enableSelection(vararg dictRule: DictRule) {
+    fun moveItemInList(fromIndex: Int, toIndex: Int) {
+        _uiRules.update { currentList ->
+            val list = currentList.toMutableList()
+            val item = list.removeAt(fromIndex)
+            list.add(toIndex, item)
+            list
+        }
+    }
+
+    fun saveSortOrder() {
+        val currentRules = _uiRules.value
         execute {
         execute {
-            val array = dictRule.map { it.copy(enabled = true) }.toTypedArray()
-            appDb.dictRuleDao.insert(*array)
+            repository.moveOrder(currentRules.map { it.rule })
         }
         }
     }
     }
 
 
-    fun disableSelection(vararg dictRule: DictRule) {
+    fun delete(vararg dictRule: DictRule) {
         execute {
         execute {
-            val array = dictRule.map { it.copy(enabled = false) }.toTypedArray()
-            appDb.dictRuleDao.insert(*array)
+            repository.delete(*dictRule)
+        }
+    }
+
+    fun upSortNumber() {
+        execute {
+            val rules = repository.getAll()
+            for ((index, rule) in rules.withIndex()) {
+                rule.sortNumber = index + 1
+            }
+            repository.insert(*rules.toTypedArray())
         }
         }
     }
     }
 
 
@@ -61,4 +188,22 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
         }
         }
     }
     }
 
 
-}
+    fun copyRule(dictRule: DictRule) {
+        context.sendToClip(GSON.toJson(dictRule))
+    }
+
+    fun pasteRule(): DictRule? {
+        val text = context.getClipText()
+        if (text.isNullOrBlank()) {
+            context.toastOnUi("剪贴板没有内容")
+            return null
+        }
+        return try {
+            GSON.fromJsonObject<DictRule>(text).getOrThrow()
+        } catch (e: Exception) {
+            context.toastOnUi("格式不对")
+            null
+        }
+    }
+
+}

+ 116 - 0
app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt

@@ -0,0 +1,116 @@
+package io.legado.app.ui.replace
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Text
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.modalBottomSheet.GlobalModalBottomSheet
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GroupManageBottomSheet(
+    groups: List<String>,
+    sheetState: SheetState,
+    onDismissRequest: () -> Unit,
+    viewModel: ReplaceRuleViewModel
+) {
+    var editingGroup by remember { mutableStateOf<String?>(null) }
+    var updatedGroupName by remember { mutableStateOf("") }
+
+    GlobalModalBottomSheet(
+        onDismissRequest = onDismissRequest,
+        sheetState = sheetState
+    ) {
+        Column(
+            modifier = Modifier
+                .padding(horizontal = 16.dp)
+                .padding(bottom = 16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            Text(
+                modifier = Modifier.padding(bottom = 16.dp),
+                text = stringResource(R.string.group_manage),
+                style = MaterialTheme.typography.titleMedium
+            )
+            LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                items(groups) { group ->
+                    Card(modifier = Modifier.fillMaxWidth()) {
+                        if (editingGroup == group) {
+                            Row(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 4.dp),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.SpaceBetween
+                            ) {
+                                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 {
+                            ListItem(
+                                headlineContent = { Text(group) },
+                                trailingContent = {
+                                    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)
+                                            )
+                                        }
+                                    }
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 0 - 258
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt

@@ -1,258 +0,0 @@
-package io.legado.app.ui.replace
-
-import android.content.Context
-import android.os.Bundle
-import android.view.View
-import android.view.ViewGroup
-import android.widget.PopupMenu
-import androidx.core.os.bundleOf
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.databinding.ItemReplaceRuleBinding
-//import io.legado.app.lib.theme.backgroundColor
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.utils.gone
-import io.legado.app.utils.themeColor
-import io.legado.app.utils.visible
-import splitties.views.backgroundColor
-
-
-class ReplaceRuleAdapter(context: Context, var callBack: CallBack) :
-    RecyclerAdapter<ReplaceRule, ItemReplaceRuleBinding>(context),
-    ItemTouchCallback.Callback {
-
-    private val selected = linkedSetOf<ReplaceRule>()
-
-    val selection: List<ReplaceRule>
-        get() {
-            return getItems().filter {
-                selected.contains(it)
-            }
-        }
-
-    val diffItemCallBack = object : DiffUtil.ItemCallback<ReplaceRule>() {
-
-        override fun areItemsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean {
-            return oldItem.id == newItem.id
-        }
-
-        override fun areContentsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean {
-            if (oldItem.name != newItem.name) return false
-            if (oldItem.group != newItem.group) return false
-            if (oldItem.isEnabled != newItem.isEnabled) return false
-            if (oldItem.order != newItem.order) return false  // 添加 order 检查
-            return true
-        }
-
-        override fun getChangePayload(oldItem: ReplaceRule, newItem: ReplaceRule): Any? {
-            val payload = Bundle()
-            if (oldItem.name != newItem.name || oldItem.group != newItem.group) {
-                payload.putBoolean("upName", true)
-            }
-            if (oldItem.isEnabled != newItem.isEnabled) {
-                payload.putBoolean("enabled", newItem.isEnabled)
-            }
-            if (oldItem.order != newItem.order) {
-                payload.putBoolean("orderChanged", true)
-            }
-            if (payload.isEmpty) {
-                return null
-            }
-            return payload
-        }
-    }
-
-    fun selectAll() {
-        getItems().forEach {
-            selected.add(it)
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    fun revertSelection() {
-        getItems().forEach {
-            if (selected.contains(it)) {
-                selected.remove(it)
-            } else {
-                selected.add(it)
-            }
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    override fun getViewBinding(parent: ViewGroup): ItemReplaceRuleBinding {
-        return ItemReplaceRuleBinding.inflate(inflater, parent, false)
-    }
-
-    override fun onCurrentListChanged() {
-        callBack.upCountView()
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemReplaceRuleBinding,
-        item: ReplaceRule,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            var needUpdatePin = payloads.isEmpty()
-
-            if (payloads.isNotEmpty()) {
-                for (i in payloads.indices) {
-                    val bundle = payloads[i] as Bundle
-                    bundle.keySet().forEach {
-                        when (it) {
-                            "selected" -> cbName.isChecked = selected.contains(item)
-                            "upName" -> cbName.text = item.getDisplayNameGroup()
-                            "enabled" -> swtEnabled.isChecked = item.isEnabled
-                            "orderChanged" -> needUpdatePin = true
-                        }
-                    }
-                }
-            }
-
-            if (payloads.isEmpty() || needUpdatePin) {
-                when (item.order) {
-                    -1 -> {
-                        ivPin.visible()
-                        ivPin.setImageResource(R.drawable.ic_praise_filled)
-                        ivPin.rotation = 0f
-                        ivPin.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorSecondary))
-                    }
-                    -2 -> {
-                        ivPin.visible()
-                        ivPin.setImageResource(R.drawable.ic_praise_filled)
-                        ivPin.rotation = 180f
-                        ivPin.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorSecondary))
-                    }
-                    else -> ivPin.gone()
-                }
-            }
-
-            if (payloads.isEmpty()) {
-                cbName.text = item.getDisplayNameGroup()
-                swtEnabled.isChecked = item.isEnabled
-                cbName.isChecked = selected.contains(item)
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemReplaceRuleBinding) {
-        binding.apply {
-            swtEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
-                if (buttonView.isPressed) {
-                    getItem(holder.layoutPosition)?.let {
-                        it.isEnabled = isChecked
-                        callBack.update(it)
-                    }
-                }
-            }
-            ivEdit.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    callBack.edit(it)
-                }
-            }
-            cbName.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    if (cbName.isChecked) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                }
-                callBack.upCountView()
-            }
-            ivMenuMore.setOnClickListener {
-                showMenu(ivMenuMore, holder.layoutPosition)
-            }
-        }
-    }
-
-    private fun showMenu(view: View, position: Int) {
-        val item = getItem(position) ?: return
-        val popupMenu = PopupMenu(context, view)
-        popupMenu.inflate(R.menu.replace_rule_item)
-        popupMenu.setOnMenuItemClickListener { menuItem ->
-            when (menuItem.itemId) {
-                R.id.menu_top -> callBack.toTop(item)
-                R.id.menu_bottom -> callBack.toBottom(item)
-                R.id.menu_del -> {
-                    callBack.delete(item)
-                    selected.remove(item)
-                }
-            }
-            true
-        }
-        popupMenu.show()
-    }
-
-    override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
-        val srcItem = getItem(srcPosition)
-        val targetItem = getItem(targetPosition)
-        if (srcItem != null && targetItem != null) {
-            if (srcItem.order == targetItem.order) {
-                callBack.upOrder()
-            } else {
-                val srcOrder = srcItem.order
-                srcItem.order = targetItem.order
-                targetItem.order = srcOrder
-                movedItems.add(srcItem)
-                movedItems.add(targetItem)
-            }
-        }
-        swapItem(srcPosition, targetPosition)
-        return true
-    }
-
-    private val movedItems = linkedSetOf<ReplaceRule>()
-
-    override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
-        if (movedItems.isNotEmpty()) {
-            callBack.update(*movedItems.toTypedArray())
-            movedItems.clear()
-        }
-    }
-
-    val dragSelectCallback: DragSelectTouchHelper.Callback =
-        object : DragSelectTouchHelper.AdvanceCallback<ReplaceRule>(Mode.ToggleAndReverse) {
-            override fun currentSelectedId(): MutableSet<ReplaceRule> {
-                return selected
-            }
-
-            override fun getItemId(position: Int): ReplaceRule {
-                return getItem(position)!!
-            }
-
-            override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
-                getItem(position)?.let {
-                    if (isSelected) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                    notifyItemChanged(position, bundleOf(Pair("selected", null)))
-                    callBack.upCountView()
-                    return true
-                }
-                return false
-            }
-            
-        }
-
-    interface CallBack {
-        fun update(vararg rule: ReplaceRule)
-        fun delete(rule: ReplaceRule)
-        fun edit(rule: ReplaceRule)
-        fun toTop(rule: ReplaceRule)
-        fun toBottom(rule: ReplaceRule)
-        fun upOrder()
-        fun upCountView()
-    }
-}

+ 41 - 389
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -3,52 +3,33 @@ package io.legado.app.ui.replace
 import android.content.ClipData
 import android.content.ClipData
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.detectDragGestures
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.ButtonDefaults
 import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Checkbox
 import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -58,21 +39,16 @@ import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.LoadingIndicator
 import androidx.compose.material3.LoadingIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MediumFlexibleTopAppBar
 import androidx.compose.material3.MediumFlexibleTopAppBar
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.PlainTooltip
 import androidx.compose.material3.PlainTooltip
 import androidx.compose.material3.PrimaryScrollableTabRow
 import androidx.compose.material3.PrimaryScrollableTabRow
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SheetState
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
 import androidx.compose.material3.SnackbarResult
-import androidx.compose.material3.Switch
 import androidx.compose.material3.Tab
 import androidx.compose.material3.Tab
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TextButton
@@ -91,16 +67,12 @@ import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.hapticfeedback.HapticFeedback
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
@@ -118,7 +90,9 @@ import io.legado.app.data.repository.UploadRepository
 import io.legado.app.ui.replace.edit.ReplaceEditActivity
 import io.legado.app.ui.replace.edit.ReplaceEditActivity
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.AnimatedText
 import io.legado.app.ui.widget.components.AnimatedText
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.EmptyMessageView
+import io.legado.app.ui.widget.components.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.SelectionBottomBar
 import io.legado.app.ui.widget.components.SelectionBottomBar
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
@@ -127,12 +101,9 @@ import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 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.lazylist.FastScrollLazyColumn
-import io.legado.app.ui.widget.components.modalBottomSheet.GlobalModalBottomSheet
-import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject
-import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.rememberReorderableLazyListState
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
@@ -148,7 +119,6 @@ fun ReplaceRuleScreen(
 
 
     //TODO: 期望换为Navigation
     //TODO: 期望换为Navigation
     val context = LocalContext.current
     val context = LocalContext.current
-    val haptic = LocalHapticFeedback.current
     val scope = rememberCoroutineScope()
     val scope = rememberCoroutineScope()
     val listState = rememberLazyListState()
     val listState = rememberLazyListState()
     val snackbarHostState = remember { SnackbarHostState() }
     val snackbarHostState = remember { SnackbarHostState() }
@@ -585,71 +555,53 @@ fun ReplaceRuleScreen(
                     verticalArrangement = Arrangement.spacedBy(8.dp)
                     verticalArrangement = Arrangement.spacedBy(8.dp)
                 ) {
                 ) {
                     items(rules, key = { it.id }) { ui ->
                     items(rules, key = { it.id }) { ui ->
-                        val isSelected = selectedRuleIds.contains(ui.id)
-                        ReorderableItem(
+                        ReorderableSelectionItem(
                             state = reorderableState,
                             state = reorderableState,
-                            key = ui.id
-                        ) { isDragging ->
-
-                            val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
-                            ReplaceRuleItem(
-                                modifier = Modifier
-                                    .padding(horizontal = 12.dp)
-                                    .zIndex(if (isDragging) 1f else 0f)
-                                    .shadow(
-                                        elevation = elevation,
-                                        shape = MaterialTheme.shapes.medium,
-                                        clip = false
-                                    )
-                                    .then(
-                                        if (canReorder) {
-                                            Modifier.longPressDraggableHandle(
-                                                onDragStarted = {
-                                                    hapticFeedback.performHapticFeedback(
-                                                        HapticFeedbackType.GestureThresholdActivate
-                                                    )
-                                                },
-                                                onDragStopped = {
-                                                    hapticFeedback.performHapticFeedback(
-                                                        HapticFeedbackType.GestureEnd
-                                                    )
-                                                },
-                                                interactionSource = remember { MutableInteractionSource() }
-                                            )
-                                        } else {
-                                            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)
+                            key = ui.id,
+                            title = ui.name,
+                            isEnabled = ui.isEnabled,
+                            isSelected = selectedRuleIds.contains(ui.id),
+                            inSelectionMode = inSelectionMode,
+                            canReorder = canReorder,
+                            onToggleSelection = {
+                                viewModel.toggleSelection(ui.id)
+                            },
+                            onEnabledChange = { enabled ->
+                                viewModel.update(ui.rule.copy(isEnabled = enabled))
+                            },
+                            onClickEdit = {
+                                context.startActivity(
+                                    ReplaceEditActivity.startIntent(
+                                        context,
+                                        ui.id
                                     )
                                     )
-                                }
-                            )
-                        }
+                                )
+                            },
+                            modifier = Modifier.padding(horizontal = 12.dp),
+                            dropdownContent = { dismiss ->
+                                DropdownMenuItem(
+                                    text = { Text("移至顶部") },
+                                    onClick = { viewModel.toTop(ui.rule); dismiss() }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("移至底部") },
+                                    onClick = { viewModel.toBottom(ui.rule); dismiss() }
+                                )
+                                DropdownMenuItem(
+                                    text = { Text("删除") },
+                                    onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
+                                )
+                            }
+                        )
                     }
                     }
                 }
                 }
                 if (inSelectionMode) {
                 if (inSelectionMode) {
                     DraggableSelectionHandler(
                     DraggableSelectionHandler(
                         listState = listState,
                         listState = listState,
-                        rules = rules,
-                        selectedRuleIds = selectedRuleIds,
+                        items = rules,
+                        selectedIds = selectedRuleIds,
                         onSelectionChange = viewModel::setSelection,
                         onSelectionChange = viewModel::setSelection,
-                        haptic = haptic,
+                        idProvider = { it.id },
                         modifier = Modifier
                         modifier = Modifier
                             .fillMaxHeight()
                             .fillMaxHeight()
                             .width(60.dp)
                             .width(60.dp)
@@ -719,303 +671,3 @@ fun ReplaceRuleScreen(
         }
         }
     }
     }
 }
 }
-
-@Composable
-fun DraggableSelectionHandler(
-    listState: LazyListState,
-    rules: List<ReplaceRuleItemUi>,
-    selectedRuleIds: Set<Long>,
-    onSelectionChange: (Set<Long>) -> Unit,
-    haptic: HapticFeedback,
-    modifier: Modifier = Modifier
-) {
-    val latestSelectedRuleIds by rememberUpdatedState(selectedRuleIds)
-    var isAddingMode by remember { mutableStateOf(true) }
-    var lastProcessedIndex by remember { mutableIntStateOf(-1) }
-
-    fun findRuleAtOffset(offsetY: Float): Pair<Int, ReplaceRuleItemUi>? {
-        val itemInfo = listState.layoutInfo.visibleItemsInfo
-            .firstOrNull { item ->
-                offsetY >= item.offset && offsetY <= item.offset + item.size
-            }
-
-        return itemInfo?.let { info ->
-            rules.getOrNull(info.index)?.let { rule ->
-                info.index to rule
-            }
-        }
-    }
-
-    fun applySelection(id: Long, add: Boolean) {
-        val current = latestSelectedRuleIds
-        onSelectionChange(
-            if (add) current + id else current - 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
-                                val current = latestSelectedRuleIds
-                                onSelectionChange(
-                                    if (current.contains(id)) current - id
-                                    else current + 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
-                                val current = latestSelectedRuleIds
-                                isAddingMode = !current.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("") }
-
-    GlobalModalBottomSheet(
-        onDismissRequest = onDismissRequest,
-        sheetState = sheetState
-    ) {
-        Column(
-            modifier = Modifier
-                .padding(horizontal = 16.dp)
-                .padding(bottom = 16.dp),
-            horizontalAlignment = Alignment.CenterHorizontally,
-        ) {
-            Text(
-                modifier = Modifier.padding(bottom = 16.dp),
-                text = stringResource(R.string.group_manage),
-                style = MaterialTheme.typography.titleMedium
-            )
-            LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
-                items(groups) { group ->
-                    Card(modifier = Modifier.fillMaxWidth()) {
-                        if (editingGroup == group) {
-                            Row(
-                                modifier = Modifier
-                                    .fillMaxWidth()
-                                    .padding(horizontal = 16.dp, vertical = 4.dp),
-                                verticalAlignment = Alignment.CenterVertically,
-                                horizontalArrangement = Arrangement.SpaceBetween
-                            ) {
-                                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 {
-                            ListItem(
-                                headlineContent = { Text(group) },
-                                trailingContent = {
-                                    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) }
-
-    val containerColor by animateColorAsState(
-        targetValue = if (isSelected)
-            MaterialTheme.colorScheme.secondaryContainer
-        else
-            MaterialTheme.colorScheme.surfaceContainerLow,
-        animationSpec = tween(
-            durationMillis = 200,
-            easing = FastOutSlowInEasing
-        ),
-        label = "CardColor"
-    )
-
-    Card(
-        onClick = { onToggleSelection() },
-        modifier = modifier
-            .fillMaxWidth(),
-        shape = MaterialTheme.shapes.medium,
-        colors = CardDefaults.cardColors(
-            containerColor = containerColor
-        )
-    ) {
-        ListItem(
-            modifier = Modifier
-                .animateContentSize(),
-            headlineContent = {
-                AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
-                    Text(
-                        text = targetName,
-                        style = MaterialTheme.typography.titleMedium,
-                        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 = {
-                                    onToTop()
-                                    showRuleMenu = false
-                                }
-                            )
-                            DropdownMenuItem(
-                                text = { Text("移至底部") },
-                                onClick = {
-                                    onToBottom()
-                                    showRuleMenu = false
-                                }
-                            )
-                            DropdownMenuItem(
-                                text = { Text("删除") },
-                                onClick = {
-                                    onDelete()
-                                    showRuleMenu = false
-                                }
-                            )
-                        }
-                    }
-                }
-            },
-            colors = ListItemDefaults.colors(
-                containerColor = Color.Transparent
-            )
-        )
-    }
-}

+ 98 - 0
app/src/main/java/io/legado/app/ui/widget/components/DraggableSelectionHandler.kt

@@ -0,0 +1,98 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalHapticFeedback
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+@Composable
+fun <T, ID> DraggableSelectionHandler(
+    listState: LazyListState,
+    modifier: Modifier = Modifier,
+    items: List<T>,
+    selectedIds: Set<ID>,
+    onSelectionChange: (Set<ID>) -> Unit,
+    idProvider: (T) -> ID,
+    haptic: HapticFeedback = LocalHapticFeedback.current,
+) {
+    val latestSelectedIds by rememberUpdatedState(selectedIds)
+    var isAddingMode by remember { mutableStateOf(true) }
+    var lastProcessedIndex by remember { mutableIntStateOf(-1) }
+
+    fun findItemAtOffset(offsetY: Float): Pair<Int, T>? {
+        val itemInfo = listState.layoutInfo.visibleItemsInfo
+            .firstOrNull { item ->
+                offsetY >= item.offset && offsetY <= item.offset + item.size
+            }
+
+        return itemInfo?.let { info ->
+            items.getOrNull(info.index)?.let { item ->
+                info.index to item
+            }
+        }
+    }
+
+    fun applySelection(id: ID, add: Boolean) {
+        val current = latestSelectedIds
+        onSelectionChange(
+            if (add) current + id else current - id
+        )
+    }
+
+    Box(
+        modifier = modifier.pointerInput(Unit) {
+            coroutineScope {
+                launch {
+                    detectTapGestures(
+                        onTap = { offset ->
+                            findItemAtOffset(offset.y)?.let { (_, item) ->
+                                val id = idProvider(item)
+                                applySelection(id, !latestSelectedIds.contains(id))
+                                haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+                            }
+                        }
+                    )
+                }
+
+                launch {
+                    detectDragGestures(
+                        onDragStart = { offset ->
+                            findItemAtOffset(offset.y)?.let { (index, item) ->
+                                lastProcessedIndex = index
+                                val id = idProvider(item)
+                                isAddingMode = !latestSelectedIds.contains(id)
+                                applySelection(id, isAddingMode)
+                                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+                            }
+                        },
+                        onDrag = { change, _ ->
+                            findItemAtOffset(change.position.y)?.let { (index, item) ->
+                                if (index != lastProcessedIndex) {
+                                    lastProcessedIndex = index
+                                    applySelection(idProvider(item), isAddingMode)
+                                    haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+                                }
+                            }
+                        },
+                        onDragEnd = { lastProcessedIndex = -1 },
+                        onDragCancel = { lastProcessedIndex = -1 }
+                    )
+                }
+            }
+        }
+    )
+}

+ 208 - 0
app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt

@@ -0,0 +1,208 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.ReorderableLazyListState
+
+@Composable
+fun SelectionItemCard(
+    title: String,
+    modifier: Modifier = Modifier,
+    subtitle: String? = null,
+    isEnabled: Boolean = true,
+    isSelected: Boolean = false,
+    inSelectionMode: Boolean = false,
+    onToggleSelection: () -> Unit = {},
+    onEnabledChange: ((Boolean) -> Unit)? = null,
+    onClickEdit: (() -> Unit)? = null,
+    trailingAction: @Composable (RowScope.() -> Unit)? = null,
+    dropdownContent: @Composable (ColumnScope.(onDismiss: () -> Unit) -> Unit)? = null
+) {
+    var showMenu by remember { mutableStateOf(false) }
+
+    val containerColor by animateColorAsState(
+        targetValue = if (isSelected)
+            MaterialTheme.colorScheme.secondaryContainer
+        else
+            MaterialTheme.colorScheme.surfaceContainerLow,
+        animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
+        label = "CardColor"
+    )
+
+    Card(
+        onClick = onToggleSelection,
+        modifier = modifier.fillMaxWidth(),
+        shape = MaterialTheme.shapes.medium,
+        colors = CardDefaults.cardColors(containerColor = containerColor)
+    ) {
+        ListItem(
+            modifier = Modifier.animateContentSize(),
+            headlineContent = {
+                Text(
+                    text = title,
+                    style = MaterialTheme.typography.titleMedium,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis
+                )
+            },
+            supportingContent = subtitle?.let {
+                {
+                    Text(
+                        text = it,
+                        style = MaterialTheme.typography.bodySmall,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            },
+            leadingContent = {
+                AnimatedVisibility(
+                    visible = inSelectionMode,
+                    enter = fadeIn() + expandHorizontally(),
+                    exit = fadeOut() + shrinkHorizontally()
+                ) {
+                    Checkbox(
+                        checked = isSelected,
+                        onCheckedChange = null
+                    )
+                }
+            },
+            trailingContent = {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    onEnabledChange?.let {
+                        Switch(
+                            checked = isEnabled,
+                            onCheckedChange = it
+                        )
+                    }
+
+                    if (onClickEdit != null) {
+                        IconButton(onClick = onClickEdit) {
+                            Icon(Icons.Default.Edit, contentDescription = "Edit")
+                        }
+                    }
+
+                    if (trailingAction != null) {
+                        trailingAction()
+                    }
+
+                    if (dropdownContent != null) {
+                        Box {
+                            IconButton(onClick = { showMenu = true }) {
+                                Icon(Icons.Default.MoreVert, contentDescription = "More")
+                            }
+                            DropdownMenu(
+                                expanded = showMenu,
+                                onDismissRequest = { showMenu = false }
+                            ) {
+                                dropdownContent { showMenu = false }
+                            }
+                        }
+                    }
+                }
+            },
+            colors = ListItemDefaults.colors(containerColor = Color.Transparent)
+        )
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun LazyItemScope.ReorderableSelectionItem(
+    state: ReorderableLazyListState,
+    key: Any,
+    title: String,
+    modifier: Modifier = Modifier,
+    subtitle: String? = null,
+    isEnabled: Boolean = true,
+    isSelected: Boolean = false,
+    inSelectionMode: Boolean = false,
+    canReorder: Boolean = true,
+    onToggleSelection: () -> Unit = {},
+    onEnabledChange: ((Boolean) -> Unit)? = null,
+    onClickEdit: (() -> Unit)? = null,
+    trailingAction: @Composable (RowScope.() -> Unit)? = null,
+    dropdownContent: @Composable (ColumnScope.(onDismiss: () -> Unit) -> Unit)? = null
+) {
+    val hapticFeedback = LocalHapticFeedback.current
+
+    ReorderableItem(state, key = key) { isDragging ->
+        val elevation by animateDpAsState(
+            targetValue = if (isDragging) 8.dp else 0.dp,
+            label = "DragElevation"
+        )
+
+        SelectionItemCard(
+            title = title,
+            subtitle = subtitle,
+            isEnabled = isEnabled,
+            isSelected = isSelected,
+            inSelectionMode = inSelectionMode,
+            onToggleSelection = onToggleSelection,
+            onEnabledChange = onEnabledChange,
+            onClickEdit = onClickEdit,
+            trailingAction = trailingAction,
+            dropdownContent = dropdownContent,
+            modifier = modifier
+                .zIndex(if (isDragging) 1f else 0f)
+                .shadow(elevation, MaterialTheme.shapes.medium)
+                .then(
+                    if (canReorder && !inSelectionMode) {
+                        Modifier.longPressDraggableHandle(
+                            onDragStarted = {
+                                hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
+                            },
+                            onDragStopped = {
+                                hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
+                            }
+                        )
+                    } else Modifier
+                )
+                .animateItem()
+        )
+    }
+}