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

This commit is contained in:
HapeLee committed 2025-12-16 01:19:57 +08:00
1 parent 14c309d5c1
commit bfb08a8541
10 files changed
+1174 -632

No files matched your search

@@ -120,6 +120,21 @@ interface ReplaceRuleDao {
) )
fun findEnabledByTitleScope(name: String, origin: String): List<ReplaceRule> 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)") @Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)")
suspend fun clearGroups(groups: List<String>) 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.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.replace.ReplaceRuleViewModel
import io.legado.app.ui.replace.edit.ReplaceEditViewModel import io.legado.app.ui.replace.edit.ReplaceEditViewModel
import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
@@ -16,6 +17,8 @@ val appModule = module {
viewModel { ReplaceEditViewModel(get(), get(), get()) } viewModel { ReplaceEditViewModel(get(), get(), get()) }
viewModel { ReplaceRuleViewModel(androidApplication()) }
// ReadRecord // ReadRecord
single { get<AppDatabase>().readRecordDao } single { get<AppDatabase>().readRecordDao }
single { get<AppDatabase>().bookDao } 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 package io.legado.app.ui.replace
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.Menu import androidx.activity.compose.setContent
import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity
import android.view.MotionEvent import io.legado.app.base.AppTheme
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
/** class ReplaceRuleActivity : AppCompatActivity() {
* 替换规则管理
*/
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())
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initRecyclerView() setContent {
initSearchView() AppTheme {
initSelectActionView() ReplaceRuleScreen(onBackClick = { finish() })
observeReplaceRuleData()
observeGroupData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.replace_rule, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
groupMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupMenu()
// 标记当前选中的排序方式
when (viewModel.sortMode.value) {
"asc" -> menu.findItem(R.id.sort_order_asc)?.isChecked = true
"desc" -> menu.findItem(R.id.sort_order_desc)?.isChecked = true
"name_asc" -> menu.findItem(R.id.sort_name_asc)?.isChecked = true
"name_desc" -> menu.findItem(R.id.sort_name_desc)?.isChecked = true
}
return super.onPrepareOptionsMenu(menu)
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.addItemDecoration(VerticalDivider(this))
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
// When this page is opened, it is in selection mode
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.queryHint = getString(R.string.replace_purify_search)
searchView.setOnQueryTextListener(this)
}
override fun selectAll(selectAll: Boolean) {
if (selectAll) {
adapter.selectAll()
} else {
adapter.revertSelection()
}
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
yesButton { viewModel.delSelection(adapter.selection) }
noButton()
}
}
private fun initSelectActionView() {
binding.selectActionBar.setMainActionText(R.string.delete)
binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun observeReplaceRuleData() {
lifecycleScope.launch {
viewModel.rulesFlow.collectLatest { rules ->
adapter.setItems(rules, adapter.diffItemCallBack)
} }
} }
} }
private fun observeGroupData() {
lifecycleScope.launch {
appDb.replaceRuleDao.flowGroups().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
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)