[新增] 基于Compose的替换规则页面,功能仍不完善
This commit is contained in:
@@ -120,6 +120,21 @@ interface ReplaceRuleDao {
|
|||||||
)
|
)
|
||||||
fun findEnabledByTitleScope(name: String, origin: String): List<ReplaceRule>
|
fun findEnabledByTitleScope(name: String, origin: String): List<ReplaceRule>
|
||||||
|
|
||||||
|
@Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id = :id")
|
||||||
|
suspend fun updateEnabled(id: Long, enabled: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE replace_rules SET isEnabled = :enabled WHERE id IN (:ids)")
|
||||||
|
suspend fun updateEnabled(ids: List<Long>, enabled: Boolean)
|
||||||
|
|
||||||
|
@Query("DELETE FROM replace_rules WHERE id IN (:ids)")
|
||||||
|
suspend fun deleteByIds(ids: List<Long>)
|
||||||
|
|
||||||
|
@Query("UPDATE replace_rules SET sortOrder = :order WHERE id = :id")
|
||||||
|
suspend fun updateOrder(id: Long, order: Int)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM replace_rules WHERE id IN (:ids)")
|
||||||
|
fun getByIds(ids: Set<Long>): List<ReplaceRule>
|
||||||
|
|
||||||
@Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)")
|
@Query("UPDATE replace_rules SET `group` = NULL WHERE `group` IN (:groups)")
|
||||||
suspend fun clearGroups(groups: List<String>)
|
suspend fun clearGroups(groups: List<String>)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
|
import android.text.TextUtils
|
||||||
|
import io.legado.app.data.appDb
|
||||||
|
import io.legado.app.data.entities.ReplaceRule
|
||||||
|
import io.legado.app.utils.splitNotBlank
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ReplaceRuleRepository {
|
||||||
|
|
||||||
|
fun flowGroups(): Flow<List<String>> {
|
||||||
|
return appDb.replaceRuleDao.flowGroups().flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flowAll(): Flow<List<ReplaceRule>> {
|
||||||
|
return appDb.replaceRuleDao.flowAll().flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flowNoGroup(): Flow<List<ReplaceRule>> {
|
||||||
|
return appDb.replaceRuleDao.flowNoGroup().flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flowGroupSearch(key: String): Flow<List<ReplaceRule>> {
|
||||||
|
return appDb.replaceRuleDao.flowGroupSearch(key).flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flowSearch(key: String): Flow<List<ReplaceRule>> {
|
||||||
|
return appDb.replaceRuleDao.flowSearch(key).flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun update(vararg rule: ReplaceRule) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
appDb.replaceRuleDao.update(*rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(rule: ReplaceRule) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
appDb.replaceRuleDao.delete(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toTop(rule: ReplaceRule) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
rule.order = -1
|
||||||
|
appDb.replaceRuleDao.update(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toBottom(rule: ReplaceRule) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
rule.order = -2
|
||||||
|
appDb.replaceRuleDao.update(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun upOrder() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val rules = appDb.replaceRuleDao.all
|
||||||
|
var normalOrder = 1
|
||||||
|
rules.forEach { rule ->
|
||||||
|
if (rule.order >= 0) {
|
||||||
|
rule.order = normalOrder++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enableSelection(rules: List<ReplaceRule>) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val array = Array(rules.size) {
|
||||||
|
rules[it].copy(isEnabled = true)
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*array)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disableSelection(rules: List<ReplaceRule>) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val array = Array(rules.size) {
|
||||||
|
rules[it].copy(isEnabled = false)
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*array)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addGroup(group: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val sources = appDb.replaceRuleDao.noGroup
|
||||||
|
sources.forEach { source ->
|
||||||
|
source.group = group
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun upGroup(oldGroup: String, newGroup: String?) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
|
||||||
|
sources.forEach { source ->
|
||||||
|
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||||
|
it.remove(oldGroup)
|
||||||
|
if (!newGroup.isNullOrEmpty())
|
||||||
|
it.add(newGroup)
|
||||||
|
source.group = TextUtils.join(",", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delGroup(group: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val sources = appDb.replaceRuleDao.getByGroup(group)
|
||||||
|
sources.forEach { source ->
|
||||||
|
source.group?.splitNotBlank(",")?.toHashSet()?.let {
|
||||||
|
it.remove(group)
|
||||||
|
source.group = TextUtils.join(",", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enableByIds(ids: Set<Long>) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
|
||||||
|
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||||
|
val updated = rules.map { it.copy(isEnabled = true) }
|
||||||
|
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disableByIds(ids: Set<Long>) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
|
||||||
|
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||||
|
val updated = rules.map { it.copy(isEnabled = false) }
|
||||||
|
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteByIds(ids: Set<Long>) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
|
||||||
|
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||||
|
appDb.replaceRuleDao.delete(*rules.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun topByIds(ids: Set<Long>) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
|
||||||
|
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||||
|
val updated = rules.map { it.copy(order = -1) }
|
||||||
|
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bottomByIds(ids: Set<Long>) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
|
||||||
|
val rules = appDb.replaceRuleDao.getByIds(ids)
|
||||||
|
val updated = rules.map { it.copy(order = -2) }
|
||||||
|
appDb.replaceRuleDao.update(*updated.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import io.legado.app.data.repository.ReadRecordRepository
|
|||||||
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
||||||
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
||||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||||
|
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
||||||
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
||||||
import org.koin.android.ext.koin.androidApplication
|
import org.koin.android.ext.koin.androidApplication
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
@@ -16,6 +17,8 @@ val appModule = module {
|
|||||||
|
|
||||||
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
||||||
|
|
||||||
|
viewModel { ReplaceRuleViewModel(androidApplication()) }
|
||||||
|
|
||||||
// ReadRecord
|
// ReadRecord
|
||||||
single { get<AppDatabase>().readRecordDao }
|
single { get<AppDatabase>().readRecordDao }
|
||||||
single { get<AppDatabase>().bookDao }
|
single { get<AppDatabase>().bookDao }
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
package io.legado.app.ui.replace
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.BaseDialogFragment
|
|
||||||
import io.legado.app.base.adapter.ItemViewHolder
|
|
||||||
import io.legado.app.base.adapter.RecyclerAdapter
|
|
||||||
import io.legado.app.data.appDb
|
|
||||||
import io.legado.app.databinding.DialogEditTextBinding
|
|
||||||
import io.legado.app.databinding.DialogRecyclerViewBinding
|
|
||||||
import io.legado.app.databinding.ItemGroupManageBinding
|
|
||||||
import io.legado.app.lib.dialogs.alert
|
|
||||||
//import io.legado.app.lib.theme.backgroundColor
|
|
||||||
//import io.legado.app.lib.theme.primaryColor
|
|
||||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
|
||||||
import io.legado.app.utils.applyTint
|
|
||||||
import io.legado.app.utils.requestInputMethod
|
|
||||||
import io.legado.app.utils.setLayout
|
|
||||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
|
||||||
import kotlinx.coroutines.flow.conflate
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
|
|
||||||
Toolbar.OnMenuItemClickListener {
|
|
||||||
|
|
||||||
private val viewModel: ReplaceRuleViewModel by activityViewModels()
|
|
||||||
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
|
|
||||||
private val adapter by lazy { GroupAdapter(requireContext()) }
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
setLayout(0.9f, 0.9f)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
// view.setBackgroundColor(backgroundColor)
|
|
||||||
// binding.toolBar.setBackgroundColor(primaryColor)
|
|
||||||
initView()
|
|
||||||
initData()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() = binding.run {
|
|
||||||
toolBar.title = getString(R.string.group_manage)
|
|
||||||
toolBar.inflateMenu(R.menu.group_manage)
|
|
||||||
toolBar.menu.applyTint(requireContext())
|
|
||||||
toolBar.setOnMenuItemClickListener(this@GroupManageDialog)
|
|
||||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
|
||||||
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initData() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
appDb.replaceRuleDao.flowGroups().conflate().collect {
|
|
||||||
adapter.setItems(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
|
||||||
when (item?.itemId) {
|
|
||||||
R.id.menu_add -> addGroup()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun addGroup() {
|
|
||||||
alert(title = getString(R.string.add_group)) {
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.setHint(R.string.group_name)
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
alertBinding.editView.text?.toString()?.let {
|
|
||||||
if (it.isNotBlank()) {
|
|
||||||
viewModel.addGroup(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelButton()
|
|
||||||
}.requestInputMethod()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun editGroup(group: String) {
|
|
||||||
alert(title = getString(R.string.group_edit)) {
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.setHint(R.string.group_name)
|
|
||||||
editView.setText(group)
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
viewModel.upGroup(group, alertBinding.editView.text?.toString())
|
|
||||||
}
|
|
||||||
cancelButton()
|
|
||||||
}.requestInputMethod()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class GroupAdapter(context: Context) :
|
|
||||||
RecyclerAdapter<String, ItemGroupManageBinding>(context) {
|
|
||||||
|
|
||||||
override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
|
|
||||||
return ItemGroupManageBinding.inflate(inflater, parent, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun convert(
|
|
||||||
holder: ItemViewHolder,
|
|
||||||
binding: ItemGroupManageBinding,
|
|
||||||
item: String,
|
|
||||||
payloads: MutableList<Any>
|
|
||||||
) {
|
|
||||||
binding.run {
|
|
||||||
//root.setBackgroundColor(context.backgroundColor)
|
|
||||||
tvGroup.text = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
|
|
||||||
binding.apply {
|
|
||||||
tvEdit.setOnClickListener {
|
|
||||||
getItem(holder.layoutPosition)?.let {
|
|
||||||
editGroup(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tvDel.setOnClickListener {
|
|
||||||
getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,362 +1,19 @@
|
|||||||
package io.legado.app.ui.replace
|
package io.legado.app.ui.replace
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.activity.compose.setContent
|
||||||
import android.view.MenuItem
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import android.view.MotionEvent
|
import io.legado.app.base.AppTheme
|
||||||
import android.view.SubMenu
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.appcompat.widget.PopupMenu
|
|
||||||
import androidx.appcompat.widget.SearchView
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.VMBaseActivity
|
|
||||||
import io.legado.app.data.appDb
|
|
||||||
import io.legado.app.data.entities.ReplaceRule
|
|
||||||
import io.legado.app.databinding.ActivityReplaceRuleBinding
|
|
||||||
import io.legado.app.databinding.DialogEditTextBinding
|
|
||||||
import io.legado.app.help.DirectLinkUpload
|
|
||||||
import io.legado.app.help.book.ContentProcessor
|
|
||||||
import io.legado.app.help.coroutine.Coroutine
|
|
||||||
import io.legado.app.lib.dialogs.alert
|
|
||||||
import io.legado.app.ui.association.ImportReplaceRuleDialog
|
|
||||||
import io.legado.app.ui.file.HandleFileContract
|
|
||||||
import io.legado.app.ui.qrcode.QrCodeResult
|
|
||||||
import io.legado.app.ui.replace.edit.ReplaceEditActivity
|
|
||||||
import io.legado.app.ui.widget.SelectActionBar
|
|
||||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
|
||||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
|
||||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
|
||||||
import io.legado.app.utils.ACache
|
|
||||||
import io.legado.app.utils.GSON
|
|
||||||
import io.legado.app.utils.hideSoftInput
|
|
||||||
import io.legado.app.utils.isAbsUrl
|
|
||||||
import io.legado.app.utils.launch
|
|
||||||
import io.legado.app.utils.readText
|
|
||||||
import io.legado.app.utils.sendToClip
|
|
||||||
import io.legado.app.utils.shouldHideSoftInput
|
|
||||||
import io.legado.app.utils.showDialogFragment
|
|
||||||
import io.legado.app.utils.showHelp
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
|
||||||
import io.legado.app.utils.toastOnUi
|
|
||||||
import io.legado.app.utils.transaction
|
|
||||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
class ReplaceRuleActivity : AppCompatActivity() {
|
||||||
* 替换规则管理
|
|
||||||
*/
|
|
||||||
class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRuleViewModel>(),
|
|
||||||
SearchView.OnQueryTextListener,
|
|
||||||
PopupMenu.OnMenuItemClickListener,
|
|
||||||
SelectActionBar.CallBack,
|
|
||||||
ReplaceRuleAdapter.CallBack {
|
|
||||||
override val binding by viewBinding(ActivityReplaceRuleBinding::inflate)
|
|
||||||
override val viewModel by viewModels<ReplaceRuleViewModel>()
|
|
||||||
private val importRecordKey = "replaceRuleRecordKey"
|
|
||||||
private val adapter by lazy { ReplaceRuleAdapter(this, this) }
|
|
||||||
private val searchView: SearchView by lazy {
|
|
||||||
binding.titleBar.findViewById(R.id.search_view)
|
|
||||||
}
|
|
||||||
private var groups = arrayListOf<String>()
|
|
||||||
private var groupMenu: SubMenu? = null
|
|
||||||
private var searchKey: String? = null
|
|
||||||
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
|
|
||||||
it ?: return@registerForActivityResult
|
|
||||||
showDialogFragment(
|
|
||||||
ImportReplaceRuleDialog(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private val editActivity =
|
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == RESULT_OK) {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val importDoc = registerForActivityResult(HandleFileContract()) {
|
|
||||||
kotlin.runCatching {
|
|
||||||
it.uri?.readText(this)?.let {
|
|
||||||
showDialogFragment(
|
|
||||||
ImportReplaceRuleDialog(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
toastOnUi("readTextError:${it.localizedMessage}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val exportResult = registerForActivityResult(HandleFileContract()) {
|
|
||||||
it.uri?.let { uri ->
|
|
||||||
alert(R.string.export_success) {
|
|
||||||
if (uri.toString().isAbsUrl()) {
|
|
||||||
setMessage(DirectLinkUpload.getSummary())
|
|
||||||
}
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = getString(R.string.path)
|
|
||||||
editView.setText(uri.toString())
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
sendToClip(uri.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
initRecyclerView()
|
setContent {
|
||||||
initSearchView()
|
AppTheme {
|
||||||
initSelectActionView()
|
ReplaceRuleScreen(onBackClick = { finish() })
|
||||||
observeReplaceRuleData()
|
|
||||||
observeGroupData()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.replace_rule, menu)
|
|
||||||
return super.onCompatCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
|
||||||
groupMenu = menu.findItem(R.id.menu_group)?.subMenu
|
|
||||||
upGroupMenu()
|
|
||||||
|
|
||||||
// 标记当前选中的排序方式
|
|
||||||
when (viewModel.sortMode.value) {
|
|
||||||
"asc" -> menu.findItem(R.id.sort_order_asc)?.isChecked = true
|
|
||||||
"desc" -> menu.findItem(R.id.sort_order_desc)?.isChecked = true
|
|
||||||
"name_asc" -> menu.findItem(R.id.sort_name_asc)?.isChecked = true
|
|
||||||
"name_desc" -> menu.findItem(R.id.sort_name_desc)?.isChecked = true
|
|
||||||
}
|
|
||||||
return super.onPrepareOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun initRecyclerView() {
|
|
||||||
//binding.recyclerView.setEdgeEffectColor(primaryColor)
|
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
binding.recyclerView.addItemDecoration(VerticalDivider(this))
|
|
||||||
val itemTouchCallback = ItemTouchCallback(adapter)
|
|
||||||
itemTouchCallback.isCanDrag = true
|
|
||||||
val dragSelectTouchHelper: DragSelectTouchHelper =
|
|
||||||
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
|
|
||||||
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
|
|
||||||
// When this page is opened, it is in selection mode
|
|
||||||
dragSelectTouchHelper.activeSlideSelect()
|
|
||||||
|
|
||||||
// Note: need judge selection first, so add ItemTouchHelper after it.
|
|
||||||
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initSearchView() {
|
|
||||||
//searchView.applyTint(primaryTextColor)
|
|
||||||
searchView.queryHint = getString(R.string.replace_purify_search)
|
|
||||||
searchView.setOnQueryTextListener(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun selectAll(selectAll: Boolean) {
|
|
||||||
if (selectAll) {
|
|
||||||
adapter.selectAll()
|
|
||||||
} else {
|
|
||||||
adapter.revertSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun revertSelection() {
|
|
||||||
adapter.revertSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClickSelectBarMainAction() {
|
|
||||||
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
|
|
||||||
yesButton { viewModel.delSelection(adapter.selection) }
|
|
||||||
noButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initSelectActionView() {
|
|
||||||
binding.selectActionBar.setMainActionText(R.string.delete)
|
|
||||||
binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel)
|
|
||||||
binding.selectActionBar.setOnMenuItemClickListener(this)
|
|
||||||
binding.selectActionBar.setCallBack(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeReplaceRuleData() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
viewModel.rulesFlow.collectLatest { rules ->
|
|
||||||
adapter.setItems(rules, adapter.diffItemCallBack)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeGroupData() {
|
}
|
||||||
lifecycleScope.launch {
|
|
||||||
appDb.replaceRuleDao.flowGroups().collect {
|
|
||||||
groups.clear()
|
|
||||||
groups.addAll(it)
|
|
||||||
upGroupMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.menu_add_replace_rule ->
|
|
||||||
editActivity.launch(ReplaceEditActivity.startIntent(this))
|
|
||||||
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
|
|
||||||
R.id.menu_del_selection -> viewModel.delSelection(adapter.selection)
|
|
||||||
R.id.menu_import_onLine -> showImportDialog()
|
|
||||||
R.id.menu_import_local -> importDoc.launch {
|
|
||||||
mode = HandleFileContract.FILE
|
|
||||||
allowExtensions = arrayOf("txt", "json")
|
|
||||||
}
|
|
||||||
R.id.menu_import_qr -> qrCodeResult.launch()
|
|
||||||
R.id.menu_help -> showHelp("replaceRuleHelp")
|
|
||||||
R.id.menu_group_null -> {
|
|
||||||
searchView.setQuery(getString(R.string.no_group), true)
|
|
||||||
}
|
|
||||||
R.id.sort_order_asc -> {
|
|
||||||
viewModel.setSortMode("asc")
|
|
||||||
item.isChecked = true
|
|
||||||
}
|
|
||||||
R.id.sort_order_desc -> {
|
|
||||||
viewModel.setSortMode("desc")
|
|
||||||
item.isChecked = true
|
|
||||||
}
|
|
||||||
R.id.sort_name_asc -> {
|
|
||||||
viewModel.setSortMode("name_asc")
|
|
||||||
item.isChecked = true
|
|
||||||
}
|
|
||||||
R.id.sort_name_desc -> {
|
|
||||||
viewModel.setSortMode("name_desc")
|
|
||||||
item.isChecked = true
|
|
||||||
}
|
|
||||||
else -> if (item.groupId == R.id.replace_group) {
|
|
||||||
searchView.setQuery("group:${item.title}", true)
|
|
||||||
viewModel.setSearchKey("group:${item.title}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observeReplaceRuleData()
|
|
||||||
return super.onCompatOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
|
||||||
when (item?.itemId) {
|
|
||||||
R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
|
|
||||||
R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
|
|
||||||
R.id.menu_top_sel -> viewModel.topSelect(adapter.selection)
|
|
||||||
R.id.menu_bottom_sel -> viewModel.bottomSelect(adapter.selection)
|
|
||||||
R.id.menu_export_selection -> exportResult.launch {
|
|
||||||
mode = HandleFileContract.EXPORT
|
|
||||||
fileData = HandleFileContract.FileData(
|
|
||||||
"exportReplaceRule.json",
|
|
||||||
GSON.toJson(adapter.selection).toByteArray(),
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upGroupMenu() = groupMenu?.transaction { menu ->
|
|
||||||
menu.removeGroup(R.id.replace_group)
|
|
||||||
groups.forEach {
|
|
||||||
menu.add(R.id.replace_group, Menu.NONE, Menu.NONE, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun showImportDialog() {
|
|
||||||
val aCache = ACache.get(cacheDir = false)
|
|
||||||
val cacheUrls: MutableList<String> = aCache
|
|
||||||
.getAsString(importRecordKey)
|
|
||||||
?.splitNotBlank(",")
|
|
||||||
?.toMutableList() ?: mutableListOf()
|
|
||||||
alert(titleResource = R.string.import_on_line) {
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = "url"
|
|
||||||
editView.setFilterValues(cacheUrls)
|
|
||||||
editView.delCallBack = {
|
|
||||||
cacheUrls.remove(it)
|
|
||||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
val text = alertBinding.editView.text?.toString()
|
|
||||||
text?.let {
|
|
||||||
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
|
|
||||||
cacheUrls.add(0, it)
|
|
||||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
showDialogFragment(
|
|
||||||
ImportReplaceRuleDialog(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
|
||||||
searchKey = newText
|
|
||||||
viewModel.setSearchKey(newText)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
Coroutine.async { ContentProcessor.upReplaceRules() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upCountView() {
|
|
||||||
binding.selectActionBar.upCountView(
|
|
||||||
adapter.selection.size,
|
|
||||||
adapter.itemCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun update(vararg rule: ReplaceRule) {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
viewModel.update(*rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(rule: ReplaceRule) {
|
|
||||||
alert(R.string.draw) {
|
|
||||||
setMessage(getString(R.string.sure_del) + "\n" + rule.name)
|
|
||||||
noButton()
|
|
||||||
yesButton {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
viewModel.delete(rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun edit(rule: ReplaceRule) {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
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
|
package io.legado.app.ui.replace
|
||||||
|
|
||||||
import android.app.Application
|
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.R
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.constant.PreferKey
|
import io.legado.app.constant.PreferKey
|
||||||
import io.legado.app.data.appDb
|
|
||||||
import io.legado.app.data.entities.ReplaceRule
|
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.getPrefString
|
||||||
import io.legado.app.utils.putPrefString
|
import io.legado.app.utils.putPrefString
|
||||||
import io.legado.app.utils.splitNotBlank
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import splitties.init.appCtx
|
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,直接修改会导致界面不刷新
|
* 修改数据要copy,直接修改会导致界面不刷新
|
||||||
*/
|
*/
|
||||||
class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
|
class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
|
||||||
|
|
||||||
|
private val repository = ReplaceRuleRepository()
|
||||||
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
||||||
private val _searchKey = MutableStateFlow<String?>(null)
|
private val _searchKey = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
val sortMode: StateFlow<String> = _sortMode
|
private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
val searchKey: StateFlow<String?> = _searchKey
|
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) {
|
fun setSortMode(mode: String) {
|
||||||
_sortMode.value = mode
|
_sortMode.value = mode
|
||||||
@@ -41,188 +155,98 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
|||||||
_searchKey.value = key
|
_searchKey.value = key
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
fun setSelection(ids: Set<Long>) {
|
||||||
val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
|
_selectedRuleIds.value = ids
|
||||||
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 update(vararg rule: ReplaceRule) {
|
fun update(vararg rule: ReplaceRule) {
|
||||||
execute {
|
execute {
|
||||||
appDb.replaceRuleDao.update(*rule)
|
repository.update(*rule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(rule: ReplaceRule) {
|
fun delete(rule: ReplaceRule) {
|
||||||
execute {
|
execute {
|
||||||
appDb.replaceRuleDao.delete(rule)
|
repository.delete(rule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toTop(rule: ReplaceRule) {
|
fun toTop(rule: ReplaceRule) {
|
||||||
execute {
|
execute {
|
||||||
rule.order = -1
|
repository.toTop(rule)
|
||||||
appDb.replaceRuleDao.update(rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun topSelect(rules: List<ReplaceRule>) {
|
|
||||||
execute {
|
|
||||||
rules.forEach {
|
|
||||||
it.order = -1
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toBottom(rule: ReplaceRule) {
|
fun toBottom(rule: ReplaceRule) {
|
||||||
execute {
|
execute {
|
||||||
rule.order = -2
|
repository.toBottom(rule)
|
||||||
appDb.replaceRuleDao.update(rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bottomSelect(rules: List<ReplaceRule>) {
|
|
||||||
execute {
|
|
||||||
rules.forEach {
|
|
||||||
it.order = -2
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upOrder() {
|
fun upOrder() {
|
||||||
execute {
|
execute {
|
||||||
// 重置所有非特殊排序的规则
|
repository.upOrder()
|
||||||
val rules = appDb.replaceRuleDao.all
|
|
||||||
var normalOrder = 1
|
|
||||||
rules.forEach { rule ->
|
|
||||||
if (rule.order >= 0) { // 只重置普通排序的规则
|
|
||||||
rule.order = normalOrder++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*rules.toTypedArray())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enableSelection(rules: List<ReplaceRule>) {
|
fun enableSelection(rules: List<ReplaceRule>) {
|
||||||
execute {
|
execute {
|
||||||
val array = Array(rules.size) {
|
repository.enableSelection(rules)
|
||||||
rules[it].copy(isEnabled = true)
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSelection(rules: List<ReplaceRule>) {
|
fun disableSelection(rules: List<ReplaceRule>) {
|
||||||
execute {
|
execute {
|
||||||
val array = Array(rules.size) {
|
repository.disableSelection(rules)
|
||||||
rules[it].copy(isEnabled = false)
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delSelection(rules: List<ReplaceRule>) {
|
fun enableSelectionByIds(ids: Set<Long>) {
|
||||||
execute {
|
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) {
|
fun addGroup(group: String) {
|
||||||
execute {
|
execute {
|
||||||
val sources = appDb.replaceRuleDao.noGroup
|
repository.addGroup(group)
|
||||||
sources.forEach { source ->
|
|
||||||
source.group = group
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*sources.toTypedArray())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upGroup(oldGroup: String, newGroup: String?) {
|
fun upGroup(oldGroup: String, newGroup: String?) {
|
||||||
execute {
|
execute {
|
||||||
val sources = appDb.replaceRuleDao.getByGroup(oldGroup)
|
repository.upGroup(oldGroup, newGroup)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delGroup(group: String) {
|
fun delGroup(group: String) {
|
||||||
execute {
|
execute {
|
||||||
execute {
|
repository.delGroup(group)
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ fun ManageGroupDialog(
|
|||||||
if (groups.isEmpty()) Text("暂无其他分组")
|
if (groups.isEmpty()) Text("暂无其他分组")
|
||||||
else Column(
|
else Column(
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
val isSelected = selected[group] ?: false
|
val isSelected = selected[group] ?: false
|
||||||
@@ -319,7 +319,7 @@ fun ManageGroupDialog(
|
|||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
)
|
)
|
||||||
.clickable { selected[group] = !isSelected }
|
.clickable { selected[group] = !isSelected }
|
||||||
.padding(8.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Checkbox(checked = isSelected, onCheckedChange = null)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
<string name="auto_dark_mode_s">夜间模式跟随系统</string>
|
<string name="auto_dark_mode_s">夜间模式跟随系统</string>
|
||||||
<string name="go_back">上级</string>
|
<string name="go_back">上级</string>
|
||||||
<string name="tone_colour">在线朗读音色</string>
|
<string name="tone_colour">在线朗读音色</string>
|
||||||
<string name="select_count">(%1$d/%2$d)</string>
|
<string name="select_count">已选 %1$d / %2$d</string>
|
||||||
<string name="show_rss">显示订阅</string>
|
<string name="show_rss">显示订阅</string>
|
||||||
<string name="service_stop">服务已停止</string>
|
<string name="service_stop">服务已停止</string>
|
||||||
<string name="service_start">正在启动服务\n具体信息请查看通知栏</string>
|
<string name="service_start">正在启动服务\n具体信息请查看通知栏</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user