[新增] 由 Compose 重写的字典规则功能
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
||||
@Dao
|
||||
interface DictRuleDao {
|
||||
|
||||
@@ -17,9 +21,15 @@ interface DictRuleDao {
|
||||
@Query("select * from dictRules order by sortNumber")
|
||||
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")
|
||||
fun getByName(name: String): DictRule?
|
||||
|
||||
@Query("SELECT * FROM dictRules WHERE name IN (:names)")
|
||||
fun getByNames(names: Set<String>): List<DictRule>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(vararg dictRule: DictRule)
|
||||
|
||||
@@ -29,4 +39,10 @@ interface DictRuleDao {
|
||||
@Delete
|
||||
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>)
|
||||
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,62 +1,29 @@
|
||||
package io.legado.app.ui.dict.rule
|
||||
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import android.annotation.SuppressLint
|
||||
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.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.help.DirectLinkUpload
|
||||
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.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.GSON
|
||||
import io.legado.app.utils.isAbsUrl
|
||||
import io.legado.app.utils.launch
|
||||
import io.legado.app.utils.readText
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.showHelp
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
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 adapter by lazy { DictRuleAdapter(this, this) }
|
||||
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
|
||||
it ?: return@registerForActivityResult
|
||||
showDialogFragment(
|
||||
ImportDictRuleDialog(it)
|
||||
)
|
||||
}
|
||||
|
||||
private val importDoc = registerForActivityResult(HandleFileContract()) {
|
||||
kotlin.runCatching {
|
||||
it.uri?.readText(this)?.let {
|
||||
@@ -88,134 +55,17 @@ class DictRuleActivity : VMBaseActivity<ActivityDictRuleBinding, DictRuleViewMod
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initRecyclerView()
|
||||
initSelectActionView()
|
||||
observeDictRuleData()
|
||||
|
||||
//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)
|
||||
}
|
||||
|
||||
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")
|
||||
private fun showImportDialog() {
|
||||
val aCache = ACache.get(cacheDir = false)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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("格式不对")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,184 @@
|
||||
package io.legado.app.ui.dict.rule
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.repository.DictRuleRepository
|
||||
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 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) {
|
||||
|
||||
private val repository = DictRuleRepository()
|
||||
private val _searchKey = MutableStateFlow<String?>(null)
|
||||
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
|
||||
|
||||
fun update(vararg dictRule: DictRule) {
|
||||
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)
|
||||
)
|
||||
|
||||
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 {
|
||||
appDb.dictRuleDao.update(*dictRule)
|
||||
}.onError {
|
||||
val msg = "更新字典规则出错\n${it.localizedMessage}"
|
||||
AppLog.put(msg, it)
|
||||
context.toastOnUi(msg)
|
||||
repository.enableByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSelectionByIds(ids: Set<String>) {
|
||||
execute {
|
||||
repository.disableByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun delSelectionByIds(ids: Set<String>) {
|
||||
execute {
|
||||
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 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 {
|
||||
repository.moveOrder(currentRules.map { it.rule })
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(vararg dictRule: DictRule) {
|
||||
execute {
|
||||
appDb.dictRuleDao.delete(*dictRule)
|
||||
}.onError {
|
||||
val msg = "删除字典规则出错\n${it.localizedMessage}"
|
||||
AppLog.put(msg, it)
|
||||
context.toastOnUi(msg)
|
||||
repository.delete(*dictRule)
|
||||
}
|
||||
}
|
||||
|
||||
fun upSortNumber() {
|
||||
execute {
|
||||
val rules = appDb.dictRuleDao.all
|
||||
val rules = repository.getAll()
|
||||
for ((index, rule) in rules.withIndex()) {
|
||||
rule.sortNumber = index + 1
|
||||
}
|
||||
appDb.dictRuleDao.insert(*rules.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSelection(vararg dictRule: DictRule) {
|
||||
execute {
|
||||
val array = dictRule.map { it.copy(enabled = true) }.toTypedArray()
|
||||
appDb.dictRuleDao.insert(*array)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSelection(vararg dictRule: DictRule) {
|
||||
execute {
|
||||
val array = dictRule.map { it.copy(enabled = false) }.toTypedArray()
|
||||
appDb.dictRuleDao.insert(*array)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -3,52 +3,33 @@ package io.legado.app.ui.replace
|
||||
import android.content.ClipData
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.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.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.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.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
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.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -58,21 +39,16 @@ import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumFlexibleTopAppBar
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -91,16 +67,12 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
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.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.EmptyMessageView
|
||||
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.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.SourceInputDialog
|
||||
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 org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
||||
@@ -148,7 +119,6 @@ fun ReplaceRuleScreen(
|
||||
|
||||
//TODO: 期望换为Navigation
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -585,71 +555,53 @@ fun ReplaceRuleScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(rules, key = { it.id }) { ui ->
|
||||
val isSelected = selectedRuleIds.contains(ui.id)
|
||||
ReorderableItem(
|
||||
ReorderableSelectionItem(
|
||||
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
|
||||
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
|
||||
)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
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) {
|
||||
DraggableSelectionHandler(
|
||||
listState = listState,
|
||||
rules = rules,
|
||||
selectedRuleIds = selectedRuleIds,
|
||||
items = rules,
|
||||
selectedIds = selectedRuleIds,
|
||||
onSelectionChange = viewModel::setSelection,
|
||||
haptic = haptic,
|
||||
idProvider = { it.id },
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user