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

This commit is contained in:
HapeLee
2025-12-16 01:19:57 +08:00
parent 14c309d5c1
commit bfb08a8541
10 changed files with 1176 additions and 634 deletions
@@ -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()
}
}
}
)
}