diff --git a/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt index 36d92d03e..4c5ea4f08 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt @@ -120,6 +120,21 @@ interface ReplaceRuleDao { ) fun findEnabledByTitleScope(name: String, origin: String): List + @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, enabled: Boolean) + + @Query("DELETE FROM replace_rules WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + @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): List + @Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)") suspend fun clearGroups(groups: List) diff --git a/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt new file mode 100644 index 000000000..c621dd2f3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt @@ -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> { + return appDb.replaceRuleDao.flowGroups().flowOn(Dispatchers.IO) + } + + fun flowAll(): Flow> { + return appDb.replaceRuleDao.flowAll().flowOn(Dispatchers.IO) + } + + fun flowNoGroup(): Flow> { + return appDb.replaceRuleDao.flowNoGroup().flowOn(Dispatchers.IO) + } + + fun flowGroupSearch(key: String): Flow> { + return appDb.replaceRuleDao.flowGroupSearch(key).flowOn(Dispatchers.IO) + } + + fun flowSearch(key: String): Flow> { + 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) { + withContext(Dispatchers.IO) { + val array = Array(rules.size) { + rules[it].copy(isEnabled = true) + } + appDb.replaceRuleDao.update(*array) + } + } + + suspend fun disableSelection(rules: List) { + 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) = + 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) = + 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) = + withContext(Dispatchers.IO) { + if (ids.isEmpty()) return@withContext + + val rules = appDb.replaceRuleDao.getByIds(ids) + appDb.replaceRuleDao.delete(*rules.toTypedArray()) + } + + suspend fun topByIds(ids: Set) = + 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) = + 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()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 983c51d3c..5996abb33 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -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().readRecordDao } single { get().bookDao } diff --git a/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt deleted file mode 100644 index 9f593cc29..000000000 --- a/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt +++ /dev/null @@ -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(context) { - - override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { - return ItemGroupManageBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemGroupManageBinding, - item: String, - payloads: MutableList - ) { - 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) } - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt index 418893e3e..01d33a1de 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt @@ -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(), - SearchView.OnQueryTextListener, - PopupMenu.OnMenuItemClickListener, - SelectActionBar.CallBack, - ReplaceRuleAdapter.CallBack { - override val binding by viewBinding(ActivityReplaceRuleBinding::inflate) - override val viewModel by viewModels() - 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() - 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() - 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 = 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() - } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt new file mode 100644 index 000000000..4b1330d76 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -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(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, + selectedRuleIds: Set, + onSelectionChange: (Set) -> Unit, + haptic: HapticFeedback, + modifier: Modifier = Modifier +) { + var isAddingMode by remember { mutableStateOf(true) } + var lastProcessedIndex by remember { mutableIntStateOf(-1) } + + fun findRuleAtOffset(offsetY: Float): Pair? { + 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, + sheetState: SheetState, + onDismissRequest: () -> Unit, + viewModel: ReplaceRuleViewModel +) { + var editingGroup by remember { mutableStateOf(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 + ) + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index 97b583b41..d5e4f80f1 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -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 = emptyList(), + val rules: List = 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(null) - val sortMode: StateFlow = _sortMode - val searchKey: StateFlow = _searchKey + private val _selectedRuleIds = MutableStateFlow>(emptySet()) + val selectedRuleIds: StateFlow> = _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 { + when (it.order) { + -1 -> Long.MIN_VALUE + -2 -> Long.MAX_VALUE + else -> it.order.toLong() + } + } + "desc" -> compareByDescending { + when (it.order) { + -1 -> Long.MAX_VALUE + -2 -> Long.MIN_VALUE + else -> it.order.toLong() + } + } + "name_asc" -> compareBy { + when (it.order) { + -1 -> Long.MIN_VALUE + -2 -> Long.MAX_VALUE + else -> 0 + } + }.thenBy { it.name.lowercase() } + "name_desc" -> compareBy { + 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> = + rulesFlow.map { rules -> + rules.map { rule -> + ReplaceRuleItemUi( + id = rule.id, + name = rule.name, + isEnabled = rule.isEnabled, + group = rule.group, + rule = rule + ) + } + } + + val uiState: StateFlow = 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 { 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 { 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 { 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 { 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) { + _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) { - 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) { - 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) { execute { - val array = Array(rules.size) { - rules[it].copy(isEnabled = true) - } - appDb.replaceRuleDao.update(*array) + repository.enableSelection(rules) } } fun disableSelection(rules: List) { execute { - val array = Array(rules.size) { - rules[it].copy(isEnabled = false) - } - appDb.replaceRuleDao.update(*array) + repository.disableSelection(rules) } } - fun delSelection(rules: List) { + fun enableSelectionByIds(ids: Set) { execute { - appDb.replaceRuleDao.delete(*rules.toTypedArray()) + repository.enableByIds(ids) } } + fun disableSelectionByIds(ids: Set) { + execute { + repository.disableByIds(ids) + } + } + + fun delSelectionByIds(ids: Set) { + execute { + repository.deleteByIds(ids) + } + } + + fun topSelectByIds(ids: Set) { + execute { + repository.topByIds(ids) + } + } + + fun bottomSelectByIds(ids: Set) { + 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) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt index cae61dc6a..1b19ed42f 100644 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt new file mode 100644 index 000000000..b09a9bdf3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt @@ -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 +) { + 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() + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 30b2e3598..fc8a1ae81 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -637,7 +637,7 @@ 夜间模式跟随系统 上级 在线朗读音色 - (%1$d/%2$d) + 已选 %1$d / %2$d 显示订阅 服务已停止 正在启动服务\n具体信息请查看通知栏