[新增] 基于Compose的替换规则页面,功能仍不完善
This commit is contained in:
@@ -120,6 +120,21 @@ interface ReplaceRuleDao {
|
||||
)
|
||||
fun findEnabledByTitleScope(name: String, origin: String): List<ReplaceRule>
|
||||
|
||||
@Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id = :id")
|
||||
suspend fun updateEnabled(id: Long, enabled: Boolean)
|
||||
|
||||
@Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id IN (:ids)")
|
||||
suspend fun updateEnabled(ids: List<Long>, enabled: Boolean)
|
||||
|
||||
@Query("DELETE FROM replace_rules WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
@Query("UPDATE replace_rules SET sortOrder = :order WHERE id = :id")
|
||||
suspend fun updateOrder(id: Long, order: Int)
|
||||
|
||||
@Query("SELECT * FROM replace_rules WHERE id IN (:ids)")
|
||||
fun getByIds(ids: Set<Long>): List<ReplaceRule>
|
||||
|
||||
@Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)")
|
||||
suspend fun clearGroups(groups: List<String>)
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package io.legado.app.data.repository
|
||||
|
||||
import android.text.TextUtils
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ReplaceRuleRepository {
|
||||
|
||||
fun flowGroups(): Flow<List<String>> {
|
||||
return appDb.replaceRuleDao.flowGroups().flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun flowAll(): Flow<List<ReplaceRule>> {
|
||||
return appDb.replaceRuleDao.flowAll().flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun flowNoGroup(): Flow<List<ReplaceRule>> {
|
||||
return appDb.replaceRuleDao.flowNoGroup().flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun flowGroupSearch(key: String): Flow<List<ReplaceRule>> {
|
||||
return appDb.replaceRuleDao.flowGroupSearch(key).flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun flowSearch(key: String): Flow<List<ReplaceRule>> {
|
||||
return appDb.replaceRuleDao.flowSearch(key).flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
suspend fun update(vararg rule: ReplaceRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
appDb.replaceRuleDao.update(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(rule: ReplaceRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
appDb.replaceRuleDao.delete(rule)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toTop(rule: ReplaceRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
rule.order = -1
|
||||
appDb.replaceRuleDao.update(rule)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toBottom(rule: ReplaceRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
rule.order = -2
|
||||
appDb.replaceRuleDao.update(rule)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upOrder() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val rules = appDb.replaceRuleDao.all
|
||||
var normalOrder = 1
|
||||
rules.forEach { rule ->
|
||||
if (rule.order >= 0) {
|
||||
rule.order = normalOrder++
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enableSelection(rules: List<ReplaceRule>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val array = Array(rules.size) {
|
||||
rules[it].copy(isEnabled = true)
|
||||
}
|
||||
appDb.replaceRuleDao.update(*array)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disableSelection(rules: List<ReplaceRule>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val array = Array(rules.size) {
|
||||
rules[it].copy(isEnabled = false)
|
||||
}
|
||||
appDb.replaceRuleDao.update(*array)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addGroup(group: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sources = appDb.replaceRuleDao.noGroup
|
||||
sources.forEach { source ->
|
||||
source.group = group
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upGroup(oldGroup: String, newGroup: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
|
||||
sources.forEach { source ->
|
||||
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||
it.remove(oldGroup)
|
||||
if (!newGroup.isNullOrEmpty())
|
||||
it.add(newGroup)
|
||||
source.group = TextUtils.join(",", it)
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delGroup(group: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sources = appDb.replaceRuleDao.getByGroup(group)
|
||||
sources.forEach { source ->
|
||||
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||
it.remove(group)
|
||||
source.group = TextUtils.join(",", it)
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enableByIds(ids: Set<Long>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
|
||||
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||
val updated = rules.map { it.copy(isEnabled = true) }
|
||||
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||
}
|
||||
|
||||
suspend fun disableByIds(ids: Set<Long>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
|
||||
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||
val updated = rules.map { it.copy(isEnabled = false) }
|
||||
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||
}
|
||||
|
||||
suspend fun deleteByIds(ids: Set<Long>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
|
||||
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||
appDb.replaceRuleDao.delete(*rules.toTypedArray())
|
||||
}
|
||||
|
||||
suspend fun topByIds(ids: Set<Long>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
|
||||
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||
val updated = rules.map { it.copy(order = -1) }
|
||||
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||
}
|
||||
|
||||
suspend fun bottomByIds(ids: Set<Long>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
|
||||
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||
val updated = rules.map { it.copy(order = -2) }
|
||||
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import io.legado.app.data.repository.ReadRecordRepository
|
||||
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
||||
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
||||
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
@@ -16,6 +17,8 @@ val appModule = module {
|
||||
|
||||
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
||||
|
||||
viewModel { ReplaceRuleViewModel(androidApplication()) }
|
||||
|
||||
// ReadRecord
|
||||
single { get<AppDatabase>().readRecordDao }
|
||||
single { get<AppDatabase>().bookDao }
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package io.legado.app.ui.replace
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseDialogFragment
|
||||
import io.legado.app.base.adapter.ItemViewHolder
|
||||
import io.legado.app.base.adapter.RecyclerAdapter
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.databinding.DialogEditTextBinding
|
||||
import io.legado.app.databinding.DialogRecyclerViewBinding
|
||||
import io.legado.app.databinding.ItemGroupManageBinding
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
//import io.legado.app.lib.theme.backgroundColor
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
||||
import io.legado.app.utils.applyTint
|
||||
import io.legado.app.utils.requestInputMethod
|
||||
import io.legado.app.utils.setLayout
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private val viewModel: ReplaceRuleViewModel by activityViewModels()
|
||||
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
|
||||
private val adapter by lazy { GroupAdapter(requireContext()) }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(0.9f, 0.9f)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
// view.setBackgroundColor(backgroundColor)
|
||||
// binding.toolBar.setBackgroundColor(primaryColor)
|
||||
initView()
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initView() = binding.run {
|
||||
toolBar.title = getString(R.string.group_manage)
|
||||
toolBar.inflateMenu(R.menu.group_manage)
|
||||
toolBar.menu.applyTint(requireContext())
|
||||
toolBar.setOnMenuItemClickListener(this@GroupManageDialog)
|
||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
lifecycleScope.launch {
|
||||
appDb.replaceRuleDao.flowGroups().conflate().collect {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_add -> addGroup()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun addGroup() {
|
||||
alert(title = getString(R.string.add_group)) {
|
||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
||||
editView.setHint(R.string.group_name)
|
||||
}
|
||||
customView { alertBinding.root }
|
||||
okButton {
|
||||
alertBinding.editView.text?.toString()?.let {
|
||||
if (it.isNotBlank()) {
|
||||
viewModel.addGroup(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelButton()
|
||||
}.requestInputMethod()
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun editGroup(group: String) {
|
||||
alert(title = getString(R.string.group_edit)) {
|
||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
||||
editView.setHint(R.string.group_name)
|
||||
editView.setText(group)
|
||||
}
|
||||
customView { alertBinding.root }
|
||||
okButton {
|
||||
viewModel.upGroup(group, alertBinding.editView.text?.toString())
|
||||
}
|
||||
cancelButton()
|
||||
}.requestInputMethod()
|
||||
}
|
||||
|
||||
private inner class GroupAdapter(context: Context) :
|
||||
RecyclerAdapter<String, ItemGroupManageBinding>(context) {
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
|
||||
return ItemGroupManageBinding.inflate(inflater, parent, false)
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: ItemGroupManageBinding,
|
||||
item: String,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
binding.run {
|
||||
//root.setBackgroundColor(context.backgroundColor)
|
||||
tvGroup.text = item
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
|
||||
binding.apply {
|
||||
tvEdit.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
editGroup(it)
|
||||
}
|
||||
}
|
||||
|
||||
tvDel.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,362 +1,19 @@
|
||||
package io.legado.app.ui.replace
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.SubMenu
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.VMBaseActivity
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.databinding.ActivityReplaceRuleBinding
|
||||
import io.legado.app.databinding.DialogEditTextBinding
|
||||
import io.legado.app.help.DirectLinkUpload
|
||||
import io.legado.app.help.book.ContentProcessor
|
||||
import io.legado.app.help.coroutine.Coroutine
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
import io.legado.app.ui.association.ImportReplaceRuleDialog
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.ui.qrcode.QrCodeResult
|
||||
import io.legado.app.ui.replace.edit.ReplaceEditActivity
|
||||
import io.legado.app.ui.widget.SelectActionBar
|
||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
||||
import io.legado.app.utils.ACache
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.hideSoftInput
|
||||
import io.legado.app.utils.isAbsUrl
|
||||
import io.legado.app.utils.launch
|
||||
import io.legado.app.utils.readText
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.shouldHideSoftInput
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.showHelp
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import io.legado.app.utils.transaction
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import io.legado.app.base.AppTheme
|
||||
|
||||
/**
|
||||
* 替换规则管理
|
||||
*/
|
||||
class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRuleViewModel>(),
|
||||
SearchView.OnQueryTextListener,
|
||||
PopupMenu.OnMenuItemClickListener,
|
||||
SelectActionBar.CallBack,
|
||||
ReplaceRuleAdapter.CallBack {
|
||||
override val binding by viewBinding(ActivityReplaceRuleBinding::inflate)
|
||||
override val viewModel by viewModels<ReplaceRuleViewModel>()
|
||||
private val importRecordKey = "replaceRuleRecordKey"
|
||||
private val adapter by lazy { ReplaceRuleAdapter(this, this) }
|
||||
private val searchView: SearchView by lazy {
|
||||
binding.titleBar.findViewById(R.id.search_view)
|
||||
}
|
||||
private var groups = arrayListOf<String>()
|
||||
private var groupMenu: SubMenu? = null
|
||||
private var searchKey: String? = null
|
||||
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
|
||||
it ?: return@registerForActivityResult
|
||||
showDialogFragment(
|
||||
ImportReplaceRuleDialog(it)
|
||||
)
|
||||
}
|
||||
private val editActivity =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
setResult(RESULT_OK)
|
||||
}
|
||||
}
|
||||
private val importDoc = registerForActivityResult(HandleFileContract()) {
|
||||
kotlin.runCatching {
|
||||
it.uri?.readText(this)?.let {
|
||||
showDialogFragment(
|
||||
ImportReplaceRuleDialog(it)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
toastOnUi("readTextError:${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
private val exportResult = registerForActivityResult(HandleFileContract()) {
|
||||
it.uri?.let { uri ->
|
||||
alert(R.string.export_success) {
|
||||
if (uri.toString().isAbsUrl()) {
|
||||
setMessage(DirectLinkUpload.getSummary())
|
||||
}
|
||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
||||
editView.hint = getString(R.string.path)
|
||||
editView.setText(uri.toString())
|
||||
}
|
||||
customView { alertBinding.root }
|
||||
okButton {
|
||||
sendToClip(uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class ReplaceRuleActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initRecyclerView()
|
||||
initSearchView()
|
||||
initSelectActionView()
|
||||
observeReplaceRuleData()
|
||||
observeGroupData()
|
||||
}
|
||||
|
||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.replace_rule, menu)
|
||||
return super.onCompatCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
groupMenu = menu.findItem(R.id.menu_group)?.subMenu
|
||||
upGroupMenu()
|
||||
|
||||
// 标记当前选中的排序方式
|
||||
when (viewModel.sortMode.value) {
|
||||
"asc" -> menu.findItem(R.id.sort_order_asc)?.isChecked = true
|
||||
"desc" -> menu.findItem(R.id.sort_order_desc)?.isChecked = true
|
||||
"name_asc" -> menu.findItem(R.id.sort_name_asc)?.isChecked = true
|
||||
"name_desc" -> menu.findItem(R.id.sort_name_desc)?.isChecked = true
|
||||
}
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
|
||||
private fun initRecyclerView() {
|
||||
//binding.recyclerView.setEdgeEffectColor(primaryColor)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.addItemDecoration(VerticalDivider(this))
|
||||
val itemTouchCallback = ItemTouchCallback(adapter)
|
||||
itemTouchCallback.isCanDrag = true
|
||||
val dragSelectTouchHelper: DragSelectTouchHelper =
|
||||
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
|
||||
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
|
||||
// When this page is opened, it is in selection mode
|
||||
dragSelectTouchHelper.activeSlideSelect()
|
||||
|
||||
// Note: need judge selection first, so add ItemTouchHelper after it.
|
||||
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
|
||||
}
|
||||
|
||||
private fun initSearchView() {
|
||||
//searchView.applyTint(primaryTextColor)
|
||||
searchView.queryHint = getString(R.string.replace_purify_search)
|
||||
searchView.setOnQueryTextListener(this)
|
||||
}
|
||||
|
||||
override fun selectAll(selectAll: Boolean) {
|
||||
if (selectAll) {
|
||||
adapter.selectAll()
|
||||
} else {
|
||||
adapter.revertSelection()
|
||||
}
|
||||
}
|
||||
|
||||
override fun revertSelection() {
|
||||
adapter.revertSelection()
|
||||
}
|
||||
|
||||
override fun onClickSelectBarMainAction() {
|
||||
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
|
||||
yesButton { viewModel.delSelection(adapter.selection) }
|
||||
noButton()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSelectActionView() {
|
||||
binding.selectActionBar.setMainActionText(R.string.delete)
|
||||
binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel)
|
||||
binding.selectActionBar.setOnMenuItemClickListener(this)
|
||||
binding.selectActionBar.setCallBack(this)
|
||||
}
|
||||
|
||||
private fun observeReplaceRuleData() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.rulesFlow.collectLatest { rules ->
|
||||
adapter.setItems(rules, adapter.diffItemCallBack)
|
||||
setContent {
|
||||
AppTheme {
|
||||
ReplaceRuleScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeGroupData() {
|
||||
lifecycleScope.launch {
|
||||
appDb.replaceRuleDao.flowGroups().collect {
|
||||
groups.clear()
|
||||
groups.addAll(it)
|
||||
upGroupMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_add_replace_rule ->
|
||||
editActivity.launch(ReplaceEditActivity.startIntent(this))
|
||||
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
|
||||
R.id.menu_del_selection -> viewModel.delSelection(adapter.selection)
|
||||
R.id.menu_import_onLine -> showImportDialog()
|
||||
R.id.menu_import_local -> importDoc.launch {
|
||||
mode = HandleFileContract.FILE
|
||||
allowExtensions = arrayOf("txt", "json")
|
||||
}
|
||||
R.id.menu_import_qr -> qrCodeResult.launch()
|
||||
R.id.menu_help -> showHelp("replaceRuleHelp")
|
||||
R.id.menu_group_null -> {
|
||||
searchView.setQuery(getString(R.string.no_group), true)
|
||||
}
|
||||
R.id.sort_order_asc -> {
|
||||
viewModel.setSortMode("asc")
|
||||
item.isChecked = true
|
||||
}
|
||||
R.id.sort_order_desc -> {
|
||||
viewModel.setSortMode("desc")
|
||||
item.isChecked = true
|
||||
}
|
||||
R.id.sort_name_asc -> {
|
||||
viewModel.setSortMode("name_asc")
|
||||
item.isChecked = true
|
||||
}
|
||||
R.id.sort_name_desc -> {
|
||||
viewModel.setSortMode("name_desc")
|
||||
item.isChecked = true
|
||||
}
|
||||
else -> if (item.groupId == R.id.replace_group) {
|
||||
searchView.setQuery("group:${item.title}", true)
|
||||
viewModel.setSearchKey("group:${item.title}")
|
||||
}
|
||||
}
|
||||
observeReplaceRuleData()
|
||||
return super.onCompatOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
|
||||
R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
|
||||
R.id.menu_top_sel -> viewModel.topSelect(adapter.selection)
|
||||
R.id.menu_bottom_sel -> viewModel.bottomSelect(adapter.selection)
|
||||
R.id.menu_export_selection -> exportResult.launch {
|
||||
mode = HandleFileContract.EXPORT
|
||||
fileData = HandleFileContract.FileData(
|
||||
"exportReplaceRule.json",
|
||||
GSON.toJson(adapter.selection).toByteArray(),
|
||||
"application/json"
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun upGroupMenu() = groupMenu?.transaction { menu ->
|
||||
menu.removeGroup(R.id.replace_group)
|
||||
groups.forEach {
|
||||
menu.add(R.id.replace_group, Menu.NONE, Menu.NONE, it)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun showImportDialog() {
|
||||
val aCache = ACache.get(cacheDir = false)
|
||||
val cacheUrls: MutableList<String> = aCache
|
||||
.getAsString(importRecordKey)
|
||||
?.splitNotBlank(",")
|
||||
?.toMutableList() ?: mutableListOf()
|
||||
alert(titleResource = R.string.import_on_line) {
|
||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
||||
editView.hint = "url"
|
||||
editView.setFilterValues(cacheUrls)
|
||||
editView.delCallBack = {
|
||||
cacheUrls.remove(it)
|
||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
||||
}
|
||||
}
|
||||
customView { alertBinding.root }
|
||||
okButton {
|
||||
val text = alertBinding.editView.text?.toString()
|
||||
text?.let {
|
||||
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
|
||||
cacheUrls.add(0, it)
|
||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
||||
}
|
||||
showDialogFragment(
|
||||
ImportReplaceRuleDialog(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
searchKey = newText
|
||||
viewModel.setSearchKey(newText)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Coroutine.async { ContentProcessor.upReplaceRules() }
|
||||
}
|
||||
|
||||
override fun upCountView() {
|
||||
binding.selectActionBar.upCountView(
|
||||
adapter.selection.size,
|
||||
adapter.itemCount
|
||||
)
|
||||
}
|
||||
|
||||
override fun update(vararg rule: ReplaceRule) {
|
||||
setResult(RESULT_OK)
|
||||
viewModel.update(*rule)
|
||||
}
|
||||
|
||||
override fun delete(rule: ReplaceRule) {
|
||||
alert(R.string.draw) {
|
||||
setMessage(getString(R.string.sure_del) + "\n" + rule.name)
|
||||
noButton()
|
||||
yesButton {
|
||||
setResult(RESULT_OK)
|
||||
viewModel.delete(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun edit(rule: ReplaceRule) {
|
||||
setResult(RESULT_OK)
|
||||
editActivity.launch(ReplaceEditActivity.startIntent(this, rule.id))
|
||||
}
|
||||
|
||||
override fun toTop(rule: ReplaceRule) {
|
||||
setResult(RESULT_OK)
|
||||
viewModel.toTop(rule)
|
||||
}
|
||||
|
||||
override fun toBottom(rule: ReplaceRule) {
|
||||
setResult(RESULT_OK)
|
||||
viewModel.toBottom(rule)
|
||||
}
|
||||
|
||||
override fun upOrder() {
|
||||
setResult(RESULT_OK)
|
||||
viewModel.upOrder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
package io.legado.app.ui.replace
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.gson.Gson
|
||||
import io.legado.app.R
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.ui.replace.edit.ReplaceEditActivity
|
||||
import io.legado.app.ui.widget.components.ActionItem
|
||||
import io.legado.app.ui.widget.components.AnimatedText
|
||||
import io.legado.app.ui.widget.components.SearchBarSection
|
||||
import io.legado.app.ui.widget.components.SelectionBottomBar
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReplaceRuleScreen(
|
||||
viewModel: ReplaceRuleViewModel = koinViewModel(),
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
//TODO: 期望换为Navigation
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val rules = uiState.rules
|
||||
val groups = uiState.groups
|
||||
|
||||
var isSearch by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
|
||||
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showGroupManageSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
val selectedRuleIds by viewModel.selectedRuleIds.collectAsState()
|
||||
val inSelectionMode = selectedRuleIds.isNotEmpty()
|
||||
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
val tabItems = listOf(stringResource(R.string.all)) + groups
|
||||
|
||||
val importDoc = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri ->
|
||||
uri?.let {
|
||||
context.contentResolver.openInputStream(it)?.use { stream ->
|
||||
val text = stream.reader().readText()
|
||||
// show import dialog
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val exportDoc = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||
onResult = { uri ->
|
||||
uri?.let {
|
||||
scope.launch {
|
||||
val rulesToExport = rules
|
||||
.filter { selectedRuleIds.contains(it.id) }
|
||||
.map { it.rule }
|
||||
|
||||
val json = Gson().toJson(rulesToExport)
|
||||
context.contentResolver.openOutputStream(it)?.use { stream ->
|
||||
stream.writer().write(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (showGroupManageSheet) {
|
||||
GroupManageBottomSheet(
|
||||
groups = groups,
|
||||
onDismissRequest = { showGroupManageSheet = false },
|
||||
sheetState = sheetState,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteRuleDialog != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteRuleDialog = null },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.delete(showDeleteRuleDialog!!)
|
||||
showDeleteRuleDialog = null
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteSelectedDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteSelectedDialog = false },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.del_msg)) },
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.delSelectionByIds(selectedRuleIds)
|
||||
viewModel.setSelection(emptySet())
|
||||
showDeleteSelectedDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteSelectedDialog = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
AnimatedText(
|
||||
text = if (inSelectionMode) {
|
||||
stringResource(
|
||||
R.string.select_count,
|
||||
selectedRuleIds.size,
|
||||
rules.size
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.replace_rule)
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (inSelectionMode) {
|
||||
viewModel.setSelection(emptySet())
|
||||
} else {
|
||||
onBackClick()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = if (inSelectionMode) "Cancel" else "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!inSelectionMode) {
|
||||
IconButton(onClick = { isSearch = !isSearch }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("在线导入") },
|
||||
onClick = { /*TODO*/ showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("本地导入") },
|
||||
onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("分组管理") },
|
||||
onClick = { showGroupManageSheet = true; showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("帮助") },
|
||||
onClick = { /*TODO*/ showMenu = false }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("旧的在前") },
|
||||
onClick = { viewModel.setSortMode("asc"); showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("新的在前") },
|
||||
onClick = { viewModel.setSortMode("desc"); showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("名称升序") },
|
||||
onClick = { viewModel.setSortMode("name_asc"); showMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("名称降序") },
|
||||
onClick = { viewModel.setSortMode("name_desc"); showMenu = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
AnimatedVisibility(visible = isSearch && !inSelectionMode) {
|
||||
SearchBarSection(
|
||||
query = uiState.searchKey ?: "",
|
||||
onQueryChange = {
|
||||
viewModel.setSearchKey(it)
|
||||
selectedTabIndex = 0
|
||||
},
|
||||
placeholder = stringResource(id = R.string.replace_purify_search)
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = !inSelectionMode) {
|
||||
val allString = stringResource(R.string.all)
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
edgePadding = 0.dp,
|
||||
divider = {},
|
||||
) {
|
||||
tabItems.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = {
|
||||
selectedTabIndex = index
|
||||
val group = tabItems.getOrNull(index)
|
||||
if (group == allString) {
|
||||
viewModel.setSearchKey("")
|
||||
} else if (group != null) {
|
||||
viewModel.setSearchKey("group:$group")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !inSelectionMode,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut()
|
||||
) {
|
||||
FloatingActionButton(onClick = {
|
||||
context.startActivity(ReplaceEditActivity.startIntent(context))
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Rule")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 8.dp,
|
||||
bottom = 120.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(rules, key = { it.id }) { ui ->
|
||||
val isSelected = selectedRuleIds.contains(ui.id)
|
||||
|
||||
ReplaceRuleItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
name = ui.name,
|
||||
isEnabled = ui.isEnabled,
|
||||
isSelected = isSelected,
|
||||
inSelectionMode = inSelectionMode,
|
||||
onEnabledChange = { enabled ->
|
||||
viewModel.update(ui.rule.copy(isEnabled = enabled))
|
||||
},
|
||||
onDelete = { showDeleteRuleDialog = ui.rule },
|
||||
onToTop = { viewModel.toTop(ui.rule) },
|
||||
onToBottom = { viewModel.toBottom(ui.rule) },
|
||||
onToggleSelection = {
|
||||
viewModel.toggleSelection(ui.id)
|
||||
},
|
||||
onClickEdit = {
|
||||
context.startActivity(
|
||||
ReplaceEditActivity.startIntent(context, ui.id)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
/*if (inSelectionMode) {
|
||||
DraggableSelectionHandler(
|
||||
listState = listState,
|
||||
rules = rules,
|
||||
selectedRuleIds = selectedRuleIds,
|
||||
onSelectionChange = viewModel::setSelection,
|
||||
haptic = haptic,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxHeight()
|
||||
.width(60.dp)
|
||||
.align(Alignment.TopStart)
|
||||
)
|
||||
}*/
|
||||
AnimatedVisibility(
|
||||
visible = inSelectionMode,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 24.dp),
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut()
|
||||
) {
|
||||
SelectionBottomBar(
|
||||
onSelectAll = {
|
||||
viewModel.setSelection(rules.map { it.id }.toSet())
|
||||
},
|
||||
onSelectInvert = {
|
||||
val allIds = rules.map { it.id }.toSet()
|
||||
viewModel.setSelection(allIds - selectedRuleIds)
|
||||
},
|
||||
primaryAction = ActionItem(
|
||||
text = stringResource(R.string.delete),
|
||||
icon = { Icon(Icons.Default.Delete, null) },
|
||||
onClick = { showDeleteSelectedDialog = true }
|
||||
),
|
||||
secondaryActions = listOf(
|
||||
ActionItem(
|
||||
text = stringResource(R.string.enable),
|
||||
onClick = {
|
||||
viewModel.enableSelectionByIds(selectedRuleIds)
|
||||
viewModel.setSelection(emptySet())
|
||||
}
|
||||
),
|
||||
ActionItem(
|
||||
text = stringResource(R.string.disable_selection),
|
||||
onClick = {
|
||||
viewModel.disableSelectionByIds(selectedRuleIds)
|
||||
viewModel.setSelection(emptySet())
|
||||
}
|
||||
),
|
||||
ActionItem(
|
||||
text = stringResource(R.string.to_top),
|
||||
onClick = {
|
||||
viewModel.topSelectByIds(selectedRuleIds)
|
||||
viewModel.setSelection(emptySet())
|
||||
}
|
||||
),
|
||||
ActionItem(
|
||||
text = stringResource(R.string.to_bottom),
|
||||
onClick = {
|
||||
viewModel.bottomSelectByIds(selectedRuleIds)
|
||||
viewModel.setSelection(emptySet())
|
||||
}
|
||||
),
|
||||
ActionItem(
|
||||
text = stringResource(R.string.export),
|
||||
onClick = { exportDoc.launch("exportReplaceRule.json") }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableSelectionHandler(
|
||||
listState: LazyListState,
|
||||
rules: List<ReplaceRuleItemUi>,
|
||||
selectedRuleIds: Set<Long>,
|
||||
onSelectionChange: (Set<Long>) -> Unit,
|
||||
haptic: HapticFeedback,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isAddingMode by remember { mutableStateOf(true) }
|
||||
var lastProcessedIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
fun findRuleAtOffset(offsetY: Float): Pair<Int, ReplaceRuleItemUi>? {
|
||||
val visibleItem = listState.layoutInfo.visibleItemsInfo.find { item ->
|
||||
offsetY >= item.offset && offsetY <= item.offset + item.size
|
||||
}
|
||||
return visibleItem?.let { item ->
|
||||
rules.getOrNull(item.index)?.let { rule ->
|
||||
item.index to rule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applySelection(id: Long, add: Boolean) {
|
||||
onSelectionChange(
|
||||
if (add) selectedRuleIds + id
|
||||
else selectedRuleIds - id
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
detectTapGestures(
|
||||
onTap = { offset ->
|
||||
val result = findRuleAtOffset(offset.y)
|
||||
if (result != null) {
|
||||
val (_, rule) = result
|
||||
val id = rule.id
|
||||
onSelectionChange(
|
||||
if (selectedRuleIds.contains(id))
|
||||
selectedRuleIds - id
|
||||
else
|
||||
selectedRuleIds + id
|
||||
)
|
||||
haptic.performHapticFeedback(
|
||||
HapticFeedbackType.TextHandleMove
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
launch {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
val result = findRuleAtOffset(offset.y)
|
||||
if (result != null) {
|
||||
val (index, rule) = result
|
||||
lastProcessedIndex = index
|
||||
|
||||
val id = rule.id
|
||||
isAddingMode = !selectedRuleIds.contains(id)
|
||||
applySelection(id, isAddingMode)
|
||||
|
||||
haptic.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
}
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val result = findRuleAtOffset(change.position.y)
|
||||
if (result != null) {
|
||||
val (index, rule) = result
|
||||
if (index != lastProcessedIndex) {
|
||||
lastProcessedIndex = index
|
||||
applySelection(rule.id, isAddingMode)
|
||||
haptic.performHapticFeedback(
|
||||
HapticFeedbackType.TextHandleMove
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
lastProcessedIndex = -1
|
||||
},
|
||||
onDragCancel = {
|
||||
lastProcessedIndex = -1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GroupManageBottomSheet(
|
||||
groups: List<String>,
|
||||
sheetState: SheetState,
|
||||
onDismissRequest: () -> Unit,
|
||||
viewModel: ReplaceRuleViewModel
|
||||
) {
|
||||
var editingGroup by remember { mutableStateOf<String?>(null) }
|
||||
var updatedGroupName by remember { mutableStateOf("") }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.group_manage), style = MaterialTheme.typography.titleLarge)
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(groups) { group ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (editingGroup == group) {
|
||||
OutlinedTextField(
|
||||
value = updatedGroupName,
|
||||
onValueChange = { updatedGroupName = it },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
viewModel.upGroup(group, updatedGroupName)
|
||||
editingGroup = null
|
||||
}) {
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(id = R.string.ok))
|
||||
}
|
||||
} else {
|
||||
Text(group, modifier = Modifier.weight(1f))
|
||||
Row {
|
||||
IconButton(onClick = {
|
||||
editingGroup = group
|
||||
updatedGroupName = group
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = stringResource(id = R.string.edit))
|
||||
}
|
||||
IconButton(onClick = { viewModel.delGroup(group) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReplaceRuleItem(
|
||||
name: String,
|
||||
isEnabled: Boolean,
|
||||
isSelected: Boolean,
|
||||
inSelectionMode: Boolean,
|
||||
onToggleSelection: () -> Unit,
|
||||
onEnabledChange: (Boolean) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToTop: () -> Unit,
|
||||
onToBottom: () -> Unit,
|
||||
onClickEdit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showRuleMenu by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { onToggleSelection() },
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
|
||||
headlineContent = {
|
||||
AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
|
||||
Text(
|
||||
text = targetName,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
AnimatedContent(
|
||||
targetState = inSelectionMode,
|
||||
label = "LeadingCheckbox"
|
||||
) { visible ->
|
||||
if (visible) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = null
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(0.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = onEnabledChange
|
||||
)
|
||||
IconButton(onClick = onClickEdit) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showRuleMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More Actions")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showRuleMenu,
|
||||
onDismissRequest = { showRuleMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("删除") },
|
||||
onClick = {
|
||||
onDelete()
|
||||
showRuleMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("置顶") },
|
||||
onClick = {
|
||||
onToTop()
|
||||
showRuleMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("置底") },
|
||||
onClick = {
|
||||
onToBottom()
|
||||
showRuleMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,150 @@
|
||||
package io.legado.app.ui.replace
|
||||
|
||||
import android.app.Application
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.data.repository.ReplaceRuleRepository
|
||||
import io.legado.app.utils.getPrefString
|
||||
import io.legado.app.utils.putPrefString
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Immutable
|
||||
data class ReplaceRuleItemUi(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val isEnabled: Boolean,
|
||||
val group: String?,
|
||||
val rule: ReplaceRule
|
||||
)
|
||||
|
||||
data class ReplaceRuleUiState(
|
||||
val sortMode: String = "desc",
|
||||
val searchKey: String? = null,
|
||||
val groups: List<String> = emptyList(),
|
||||
val rules: List<ReplaceRuleItemUi> = emptyList(),
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 替换规则数据修改
|
||||
* 修改数据要copy,直接修改会导致界面不刷新
|
||||
*/
|
||||
class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private val repository = ReplaceRuleRepository()
|
||||
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
||||
private val _searchKey = MutableStateFlow<String?>(null)
|
||||
|
||||
val sortMode: StateFlow<String> = _sortMode
|
||||
val searchKey: StateFlow<String?> = _searchKey
|
||||
private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
|
||||
val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds
|
||||
|
||||
fun toggleSelection(id: Long) {
|
||||
_selectedRuleIds.update {
|
||||
if (it.contains(id)) it - id else it + id
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
|
||||
Pair(search, sort)
|
||||
}.flatMapLatest { (searchKey, sortMode) ->
|
||||
// 先获取基础数据
|
||||
val baseFlow = when {
|
||||
searchKey.isNullOrEmpty() -> repository.flowAll()
|
||||
searchKey == appCtx.getString(R.string.no_group) -> repository.flowNoGroup()
|
||||
searchKey.startsWith("group:") -> {
|
||||
val key = searchKey.substringAfter("group:")
|
||||
repository.flowGroupSearch("%$key%")
|
||||
}
|
||||
else -> repository.flowSearch("%$searchKey%")
|
||||
}
|
||||
|
||||
baseFlow
|
||||
.map { rules ->
|
||||
val comparator = when (sortMode) {
|
||||
"asc" -> compareBy<ReplaceRule> {
|
||||
when (it.order) {
|
||||
-1 -> Long.MIN_VALUE
|
||||
-2 -> Long.MAX_VALUE
|
||||
else -> it.order.toLong()
|
||||
}
|
||||
}
|
||||
"desc" -> compareByDescending<ReplaceRule> {
|
||||
when (it.order) {
|
||||
-1 -> Long.MAX_VALUE
|
||||
-2 -> Long.MIN_VALUE
|
||||
else -> it.order.toLong()
|
||||
}
|
||||
}
|
||||
"name_asc" -> compareBy<ReplaceRule> {
|
||||
when (it.order) {
|
||||
-1 -> Long.MIN_VALUE
|
||||
-2 -> Long.MAX_VALUE
|
||||
else -> 0
|
||||
}
|
||||
}.thenBy { it.name.lowercase() }
|
||||
"name_desc" -> compareBy<ReplaceRule> {
|
||||
when (it.order) {
|
||||
-1 -> Long.MIN_VALUE
|
||||
-2 -> Long.MAX_VALUE
|
||||
else -> 0
|
||||
}
|
||||
}.thenByDescending { it.name.lowercase() }
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (comparator != null) rules.sortedWith(comparator) else rules
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
private val ruleUiFlow: Flow<List<ReplaceRuleItemUi>> =
|
||||
rulesFlow.map { rules ->
|
||||
rules.map { rule ->
|
||||
ReplaceRuleItemUi(
|
||||
id = rule.id,
|
||||
name = rule.name,
|
||||
isEnabled = rule.isEnabled,
|
||||
group = rule.group,
|
||||
rule = rule
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<ReplaceRuleUiState> = combine(
|
||||
_sortMode,
|
||||
_searchKey,
|
||||
repository.flowGroups(),
|
||||
ruleUiFlow
|
||||
) { sortMode, searchKey, groups, rules ->
|
||||
ReplaceRuleUiState(
|
||||
sortMode = sortMode,
|
||||
searchKey = searchKey,
|
||||
groups = groups,
|
||||
rules = rules,
|
||||
isLoading = false
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = ReplaceRuleUiState(isLoading = true)
|
||||
)
|
||||
|
||||
fun setSortMode(mode: String) {
|
||||
_sortMode.value = mode
|
||||
@@ -41,188 +155,98 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
||||
_searchKey.value = key
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
|
||||
Pair(search, sort)
|
||||
}.flatMapLatest { (searchKey, sortMode) ->
|
||||
// 先获取基础数据
|
||||
val baseFlow = when {
|
||||
searchKey.isNullOrEmpty() -> appDb.replaceRuleDao.flowAll()
|
||||
searchKey == appCtx.getString(R.string.no_group) -> appDb.replaceRuleDao.flowNoGroup()
|
||||
searchKey.startsWith("group:") -> {
|
||||
val key = searchKey.substringAfter("group:")
|
||||
appDb.replaceRuleDao.flowGroupSearch("%$key%")
|
||||
}
|
||||
else -> appDb.replaceRuleDao.flowSearch("%$searchKey%")
|
||||
}
|
||||
|
||||
baseFlow.map { rules ->
|
||||
val comparator = when (sortMode) {
|
||||
"asc" -> Comparator<ReplaceRule> { a, b ->
|
||||
when {
|
||||
// 置顶优先
|
||||
a.order == -1 && b.order != -1 -> -1
|
||||
a.order != -1 && b.order == -1 -> 1
|
||||
// 置底最后
|
||||
a.order == -2 && b.order != -2 -> 1
|
||||
a.order != -2 && b.order == -2 -> -1
|
||||
// 普通规则按order排序
|
||||
else -> a.order.compareTo(b.order)
|
||||
}
|
||||
}
|
||||
"desc" -> Comparator<ReplaceRule> { a, b ->
|
||||
when {
|
||||
a.order == -1 && b.order != -1 -> -1
|
||||
a.order != -1 && b.order == -1 -> 1
|
||||
a.order == -2 && b.order != -2 -> 1
|
||||
a.order != -2 && b.order == -2 -> -1
|
||||
else -> b.order.compareTo(a.order)
|
||||
}
|
||||
}
|
||||
"name_asc" -> Comparator<ReplaceRule> { a, b ->
|
||||
when {
|
||||
a.order == -1 && b.order != -1 -> -1
|
||||
a.order != -1 && b.order == -1 -> 1
|
||||
a.order == -2 && b.order != -2 -> 1
|
||||
a.order != -2 && b.order == -2 -> -1
|
||||
else -> a.name.lowercase().compareTo(b.name.lowercase())
|
||||
}
|
||||
}
|
||||
"name_desc" -> Comparator<ReplaceRule> { a, b ->
|
||||
when {
|
||||
a.order == -1 && b.order != -1 -> -1
|
||||
a.order != -1 && b.order == -1 -> 1
|
||||
a.order == -2 && b.order != -2 -> 1
|
||||
a.order != -2 && b.order == -2 -> -1
|
||||
else -> b.name.lowercase().compareTo(a.name.lowercase())
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (comparator != null) rules.sortedWith(comparator) else rules
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
fun setSelection(ids: Set<Long>) {
|
||||
_selectedRuleIds.value = ids
|
||||
}
|
||||
|
||||
fun update(vararg rule: ReplaceRule) {
|
||||
execute {
|
||||
appDb.replaceRuleDao.update(*rule)
|
||||
repository.update(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(rule: ReplaceRule) {
|
||||
execute {
|
||||
appDb.replaceRuleDao.delete(rule)
|
||||
repository.delete(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun toTop(rule: ReplaceRule) {
|
||||
execute {
|
||||
rule.order = -1
|
||||
appDb.replaceRuleDao.update(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun topSelect(rules: List<ReplaceRule>) {
|
||||
execute {
|
||||
rules.forEach {
|
||||
it.order = -1
|
||||
}
|
||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
||||
repository.toTop(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun toBottom(rule: ReplaceRule) {
|
||||
execute {
|
||||
rule.order = -2
|
||||
appDb.replaceRuleDao.update(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun bottomSelect(rules: List<ReplaceRule>) {
|
||||
execute {
|
||||
rules.forEach {
|
||||
it.order = -2
|
||||
}
|
||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
||||
repository.toBottom(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun upOrder() {
|
||||
execute {
|
||||
// 重置所有非特殊排序的规则
|
||||
val rules = appDb.replaceRuleDao.all
|
||||
var normalOrder = 1
|
||||
rules.forEach { rule ->
|
||||
if (rule.order >= 0) { // 只重置普通排序的规则
|
||||
rule.order = normalOrder++
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
||||
repository.upOrder()
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSelection(rules: List<ReplaceRule>) {
|
||||
execute {
|
||||
val array = Array(rules.size) {
|
||||
rules[it].copy(isEnabled = true)
|
||||
}
|
||||
appDb.replaceRuleDao.update(*array)
|
||||
repository.enableSelection(rules)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSelection(rules: List<ReplaceRule>) {
|
||||
execute {
|
||||
val array = Array(rules.size) {
|
||||
rules[it].copy(isEnabled = false)
|
||||
}
|
||||
appDb.replaceRuleDao.update(*array)
|
||||
repository.disableSelection(rules)
|
||||
}
|
||||
}
|
||||
|
||||
fun delSelection(rules: List<ReplaceRule>) {
|
||||
fun enableSelectionByIds(ids: Set<Long>) {
|
||||
execute {
|
||||
appDb.replaceRuleDao.delete(*rules.toTypedArray())
|
||||
repository.enableByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSelectionByIds(ids: Set<Long>) {
|
||||
execute {
|
||||
repository.disableByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun delSelectionByIds(ids: Set<Long>) {
|
||||
execute {
|
||||
repository.deleteByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun topSelectByIds(ids: Set<Long>) {
|
||||
execute {
|
||||
repository.topByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fun bottomSelectByIds(ids: Set<Long>) {
|
||||
execute {
|
||||
repository.bottomByIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addGroup(group: String) {
|
||||
execute {
|
||||
val sources = appDb.replaceRuleDao.noGroup
|
||||
sources.forEach { source ->
|
||||
source.group = group
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
repository.addGroup(group)
|
||||
}
|
||||
}
|
||||
|
||||
fun upGroup(oldGroup: String, newGroup: String?) {
|
||||
execute {
|
||||
val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
|
||||
sources.forEach { source ->
|
||||
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||
it.remove(oldGroup)
|
||||
if (!newGroup.isNullOrEmpty())
|
||||
it.add(newGroup)
|
||||
source.group = TextUtils.join(",", it)
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
repository.upGroup(oldGroup, newGroup)
|
||||
}
|
||||
}
|
||||
|
||||
fun delGroup(group: String) {
|
||||
execute {
|
||||
execute {
|
||||
val sources = appDb.replaceRuleDao.getByGroup(group)
|
||||
sources.forEach { source ->
|
||||
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||
it.remove(group)
|
||||
source.group = TextUtils.join(",", it)
|
||||
}
|
||||
}
|
||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||
}
|
||||
repository.delGroup(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,7 +306,7 @@ fun ManageGroupDialog(
|
||||
if (groups.isEmpty()) Text("暂无其他分组")
|
||||
else Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
val isSelected = selected[group] ?: false
|
||||
@@ -319,7 +319,7 @@ fun ManageGroupDialog(
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.clickable { selected[group] = !isSelected }
|
||||
.padding(8.dp),
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = null)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package io.legado.app.ui.widget.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ActionItem(
|
||||
val text: String,
|
||||
val icon: @Composable (() -> Unit)? = null,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun SelectionBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
onSelectAll: () -> Unit,
|
||||
onSelectInvert: () -> Unit,
|
||||
primaryAction: ActionItem,
|
||||
secondaryActions: List<ActionItem>
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
HorizontalFloatingToolbar(
|
||||
modifier = modifier,
|
||||
expanded = true,
|
||||
leadingContent = {
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = "Select All"
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSelectInvert) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Invert Selection"
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (secondaryActions.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More actions"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
secondaryActions.forEach { action ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(action.text) },
|
||||
leadingIcon = action.icon,
|
||||
onClick = {
|
||||
action.onClick()
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above
|
||||
),
|
||||
tooltip = { PlainTooltip { Text(primaryAction.text) } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.width(64.dp),
|
||||
onClick = primaryAction.onClick,
|
||||
) {
|
||||
primaryAction.icon?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user