[新增] 由Compose重写的替换净化编辑界面,没啥变化,可能有BUG
This commit is contained in:
@@ -33,9 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule
|
||||
import io.legado.app.data.entities.rule.ExploreRule
|
||||
import io.legado.app.data.entities.rule.SearchRule
|
||||
import io.legado.app.di.appDatabaseModule
|
||||
import io.legado.app.di.bookmarkModule
|
||||
import io.legado.app.di.exploreModule
|
||||
import io.legado.app.di.readRecordModule
|
||||
import io.legado.app.di.appModule
|
||||
import io.legado.app.help.AppFreezeMonitor
|
||||
import io.legado.app.help.AppWebDav
|
||||
import io.legado.app.help.CrashHandler
|
||||
@@ -80,7 +78,7 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
startKoin {
|
||||
androidContext(this@App)
|
||||
modules(appDatabaseModule, readRecordModule, exploreModule, bookmarkModule)
|
||||
modules(appDatabaseModule, appModule)
|
||||
}
|
||||
if (getPrefString("app_theme", "0") == "12") {
|
||||
if (AppConfig.customMode == "accent")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.data.AppDatabase
|
||||
import io.legado.app.data.repository.ExploreRepository
|
||||
import io.legado.app.data.repository.ExploreRepositoryImpl
|
||||
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.edit.ReplaceEditViewModel
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
|
||||
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
||||
|
||||
// ReadRecord
|
||||
single { get<AppDatabase>().readRecordDao }
|
||||
single { get<AppDatabase>().bookDao }
|
||||
single { get<AppDatabase>().bookChapterDao }
|
||||
single { ReadRecordRepository(get()) }
|
||||
viewModel { ReadRecordViewModel(get(), get(), get()) }
|
||||
|
||||
// Explore
|
||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||
viewModel { ExploreShowViewModel(get()) }
|
||||
|
||||
// Bookmark
|
||||
viewModel {
|
||||
AllBookmarkViewModel(
|
||||
androidApplication(),
|
||||
get()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val bookmarkModule = module {
|
||||
|
||||
viewModel {
|
||||
AllBookmarkViewModel(
|
||||
androidApplication(),
|
||||
get()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.data.repository.ExploreRepository
|
||||
import io.legado.app.data.repository.ExploreRepositoryImpl
|
||||
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val exploreModule = module {
|
||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||
viewModel { ExploreShowViewModel(get()) }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.data.AppDatabase
|
||||
import io.legado.app.data.repository.ReadRecordRepository
|
||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val readRecordModule = module {
|
||||
single { get<AppDatabase>().readRecordDao }
|
||||
single { get<AppDatabase>().bookDao }
|
||||
single { get<AppDatabase>().bookChapterDao }
|
||||
single { ReadRecordRepository(get()) }
|
||||
|
||||
viewModel { ReadRecordViewModel(get(), get(), get()) }
|
||||
}
|
||||
@@ -92,7 +92,6 @@ import io.legado.app.model.BookShelfState
|
||||
import io.legado.app.ui.widget.components.AnimatedTextButton
|
||||
import io.legado.app.ui.widget.components.AnimatedTextLine
|
||||
import io.legado.app.ui.widget.components.Cover
|
||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||
import io.legado.app.ui.widget.components.SearchBarSection
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -2,37 +2,15 @@ package io.legado.app.ui.replace.edit
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.VMBaseActivity
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.databinding.ActivityReplaceEditBinding
|
||||
import io.legado.app.lib.dialogs.SelectItem
|
||||
import io.legado.app.ui.widget.keyboard.KeyboardToolPop
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.imeHeight
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
|
||||
import io.legado.app.utils.showHelp
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.base.AppTheme
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
|
||||
/**
|
||||
* 编辑替换规则
|
||||
*/
|
||||
class ReplaceEditActivity :
|
||||
VMBaseActivity<ActivityReplaceEditBinding, ReplaceEditViewModel>(),
|
||||
KeyboardToolPop.CallBack {
|
||||
class ReplaceEditActivity : BaseComposeActivity() {
|
||||
|
||||
companion object {
|
||||
fun startIntent(
|
||||
@@ -53,161 +31,24 @@ class ReplaceEditActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override val binding by viewBinding(ActivityReplaceEditBinding::inflate)
|
||||
override val viewModel by viewModels<ReplaceEditViewModel>()
|
||||
private val softKeyboardTool by lazy {
|
||||
KeyboardToolPop(this, lifecycleScope, binding.root, this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
softKeyboardTool.attachToWindow(window)
|
||||
initView()
|
||||
viewModel.initData(intent, ::upReplaceView)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
softKeyboardTool.dismiss()
|
||||
}
|
||||
|
||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.replace_edit, menu)
|
||||
return super.onCompatCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_save -> viewModel.save(getReplaceRule()) {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
R.id.menu_copy_rule -> sendToClip(GSON.toJson(getReplaceRule()))
|
||||
R.id.menu_paste_rule -> viewModel.pasteRule(::upReplaceView)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun initView() = binding.run {
|
||||
ivHelp.setOnClickListener { showHelp("regexHelp") }
|
||||
ivManageGroups.setOnClickListener { showGroupManageDialog() }
|
||||
|
||||
root.setOnApplyWindowInsetsListenerCompat { _, insets ->
|
||||
softKeyboardTool.initialPadding = insets.imeHeight
|
||||
insets
|
||||
}
|
||||
|
||||
setupGroupAutoComplete()
|
||||
}
|
||||
|
||||
private fun setupGroupAutoComplete() = binding.run {
|
||||
lifecycleScope.launch {
|
||||
appDb.replaceRuleDao.flowGroups().collectLatest { groups ->
|
||||
val allGroups = listOf("默认") + groups
|
||||
val adapter = ArrayAdapter(
|
||||
this@ReplaceEditActivity,
|
||||
android.R.layout.simple_dropdown_item_1line,
|
||||
allGroups
|
||||
@Composable
|
||||
override fun Content() {
|
||||
setContent {
|
||||
AppTheme {
|
||||
ReplaceEditScreen(
|
||||
onBack = { finish() },
|
||||
onSaveSuccess = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
},
|
||||
onShowHelp = { key -> showHelp(key) }
|
||||
)
|
||||
etGroup.setAdapter(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
val defaultGroup = viewModel.replaceRule?.group ?: "默认"
|
||||
etGroup.setText(defaultGroup, false)
|
||||
}
|
||||
|
||||
|
||||
private fun showGroupManageDialog() {
|
||||
lifecycleScope.launch {
|
||||
val allGroups = appDb.replaceRuleDao.allGroups()
|
||||
|
||||
if (allGroups.isEmpty()) {
|
||||
toastOnUi("暂无分组")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val checked = BooleanArray(allGroups.size)
|
||||
|
||||
alert(title = getString(R.string.group_manage)) {
|
||||
multiChoiceItems(allGroups.toTypedArray(), checked) { _, _, _ -> }
|
||||
negativeButton("关闭")
|
||||
neutralButton("删除所选") {
|
||||
|
||||
val toDelete = allGroups.filterIndexed { i, _ -> checked[i] }
|
||||
|
||||
if (toDelete.isEmpty()) {
|
||||
toastOnUi("未选择任何分组")
|
||||
return@neutralButton
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
appDb.replaceRuleDao.clearGroups(toDelete)
|
||||
toastOnUi("已清空 ${toDelete.size} 个分组")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upReplaceView(rule: ReplaceRule) = binding.run {
|
||||
etName.setText(rule.name)
|
||||
val group = rule.group.takeUnless { it.isNullOrBlank() } ?: "默认"
|
||||
etGroup.setText(group, false)
|
||||
etReplaceRule.setText(rule.pattern)
|
||||
cbUseRegex.isChecked = rule.isRegex
|
||||
etReplaceTo.setText(rule.replacement)
|
||||
cbScopeTitle.isChecked = rule.scopeTitle
|
||||
cbScopeContent.isChecked = rule.scopeContent
|
||||
etScope.setText(rule.scope)
|
||||
etExcludeScope.setText(rule.excludeScope)
|
||||
etTimeout.setText(rule.timeoutMillisecond.toString())
|
||||
private fun showHelp(key: String) {
|
||||
|
||||
}
|
||||
|
||||
private fun getReplaceRule(): ReplaceRule = binding.run {
|
||||
val rule = viewModel.replaceRule ?: ReplaceRule()
|
||||
|
||||
rule.name = etName.text.toString()
|
||||
val groupText = binding.etGroup.text.toString().trim()
|
||||
rule.group = when {
|
||||
groupText.isBlank() -> null
|
||||
groupText == "默认" -> null
|
||||
else -> groupText
|
||||
}
|
||||
rule.pattern = etReplaceRule.text.toString()
|
||||
rule.isRegex = cbUseRegex.isChecked
|
||||
rule.replacement = etReplaceTo.text.toString()
|
||||
rule.scopeTitle = cbScopeTitle.isChecked
|
||||
rule.scopeContent = cbScopeContent.isChecked
|
||||
rule.scope = etScope.text.toString()
|
||||
rule.excludeScope = etExcludeScope.text.toString()
|
||||
rule.timeoutMillisecond = etTimeout.text.toString().ifEmpty { "3000" }.toLong()
|
||||
|
||||
rule
|
||||
}
|
||||
|
||||
override fun helpActions(): List<SelectItem<String>> =
|
||||
listOf(SelectItem("正则教程", "regexHelp"))
|
||||
|
||||
override fun onHelpActionSelect(action: String) {
|
||||
if (action == "regexHelp") showHelp("regexHelp")
|
||||
}
|
||||
|
||||
override fun sendText(text: String) {
|
||||
if (text.isBlank()) return
|
||||
val view = window?.decorView?.findFocus()
|
||||
if (view is EditText) {
|
||||
val start = view.selectionStart
|
||||
val end = view.selectionEnd
|
||||
val edit = view.editableText
|
||||
if (start < 0 || start >= edit.length) {
|
||||
edit.append(text)
|
||||
} else if (start > end) {
|
||||
edit.replace(end, start, text)
|
||||
} else {
|
||||
edit.replace(start, end, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package io.legado.app.ui.replace.edit
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun keyboardAsState(): State<Boolean> {
|
||||
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
|
||||
return rememberUpdatedState(isImeVisible)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReplaceEditScreen(
|
||||
onBack: () -> Unit,
|
||||
onSaveSuccess: () -> Unit,
|
||||
onShowHelp: (String) -> Unit,
|
||||
viewModel: ReplaceEditViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val isKeyboardVisible by keyboardAsState()
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
},
|
||||
actions = {
|
||||
|
||||
IconButton(onClick = { onShowHelp("regexHelp") }) {
|
||||
Icon(Icons.AutoMirrored.Filled.HelpOutline, contentDescription = "帮助")
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
viewModel.save(onSaveSuccess)
|
||||
}) {
|
||||
Icon(Icons.Default.Save, contentDescription = "保存")
|
||||
}
|
||||
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "更多操作")
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制规则") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
viewModel.copyRule()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("粘贴规则") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
viewModel.pasteRule(onSuccess = {})
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text("规则名称") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Name },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
GroupSelector(
|
||||
currentGroup = state.group,
|
||||
allGroups = state.allGroups,
|
||||
onGroupChange = viewModel::onGroupChange,
|
||||
onManageClick = { viewModel.toggleGroupDialog(true) }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.pattern,
|
||||
onValueChange = viewModel::onPatternChange,
|
||||
label = { Text("匹配规则") },
|
||||
placeholder = { Text("输入正则表达式或关键字") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Pattern }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.replacement,
|
||||
onValueChange = viewModel::onReplacementChange,
|
||||
label = { Text("替换为") },
|
||||
placeholder = { Text("输入替换内容或捕获组") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Replacement }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.scopeTitle,
|
||||
onClick = { viewModel.onScopeTitleChange(!state.scopeTitle) },
|
||||
label = { Text("标题") },
|
||||
leadingIcon = if (state.scopeTitle) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) }
|
||||
} else null
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
FilterChip(
|
||||
selected = state.scopeContent,
|
||||
onClick = { viewModel.onScopeContentChange(!state.scopeContent) },
|
||||
label = { Text("内容") },
|
||||
leadingIcon = if (state.scopeContent) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) }
|
||||
} else null
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
FilterChip(
|
||||
selected = state.isRegex,
|
||||
onClick = { viewModel.onRegexChange(!state.isRegex) },
|
||||
label = { Text("使用正则") },
|
||||
leadingIcon = if (state.isRegex) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = "正则已启用", Modifier.size(FilterChipDefaults.IconSize)) }
|
||||
} else null
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.scope,
|
||||
onValueChange = viewModel::onScopeChange,
|
||||
label = { Text("特定范围") },
|
||||
placeholder = { Text("指定规则适用的范围") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Scope }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.excludeScope,
|
||||
onValueChange = viewModel::onExcludeScopeChange,
|
||||
label = { Text("排除范围") },
|
||||
placeholder = { Text("指定规则不适用的范围") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Exclude }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.timeout,
|
||||
onValueChange = viewModel::onTimeoutChange,
|
||||
label = { Text("超时 (ms)") },
|
||||
placeholder = { Text("3000") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isKeyboardVisible,
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { fullHeight -> fullHeight },
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { fullHeight -> fullHeight },
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding()
|
||||
) {
|
||||
QuickInputBar(
|
||||
onInsert = { text -> viewModel.insertTextAtCursor(text) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (state.showGroupDialog) {
|
||||
ManageGroupDialog(
|
||||
groups = state.allGroups.filter { it != "默认" },
|
||||
onDismiss = { viewModel.toggleGroupDialog(false) },
|
||||
onDelete = { viewModel.deleteGroups(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupSelector(
|
||||
currentGroup: String,
|
||||
allGroups: List<String>,
|
||||
onGroupChange: (String) -> Unit,
|
||||
onManageClick: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentGroup,
|
||||
onValueChange = onGroupChange,
|
||||
label = { Text("分组") },
|
||||
placeholder = {Text("默认")},
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(
|
||||
ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
allGroups.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(selectionOption) },
|
||||
onClick = {
|
||||
onGroupChange(selectionOption)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onManageClick) {
|
||||
Icon(Icons.Default.Settings, "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ManageGroupDialog(
|
||||
groups: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (List<String>) -> Unit
|
||||
) {
|
||||
|
||||
val selected = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("分组管理") },
|
||||
text = {
|
||||
if (groups.isEmpty()) Text("暂无其他分组")
|
||||
else Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
val isSelected = selected[group] ?: false
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(
|
||||
color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.clickable { selected[group] = !isSelected }
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = null)
|
||||
Text(group, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val toDelete = selected.filter { it.value }.keys.toList()
|
||||
onDelete(toDelete)
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text("删除选中")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickInputBar(
|
||||
onInsert: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val symbols = listOf(".*", "\\d+", "\\w+", "[]", "()", "^", "$", "|", "{}", "<>")
|
||||
|
||||
BottomAppBar(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
) {
|
||||
symbols.forEach { symbol ->
|
||||
AssistChip(
|
||||
onClick = { onInsert(symbol) },
|
||||
label = { Text(symbol) },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,259 @@
|
||||
package io.legado.app.ui.replace.edit
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.data.appDb
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.data.dao.ReplaceRuleDao
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
import io.legado.app.utils.*
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.fromJsonObject
|
||||
import io.legado.app.utils.getClipText
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ReplaceEditViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
var replaceRule: ReplaceRule? = null
|
||||
data class ReplaceEditUiState(
|
||||
val id: Long = -1,
|
||||
val name: TextFieldValue = TextFieldValue(""),
|
||||
val group: String = "默认",
|
||||
val pattern: TextFieldValue = TextFieldValue(""),
|
||||
val replacement: TextFieldValue = TextFieldValue(""),
|
||||
val isRegex: Boolean = false,
|
||||
val scope: TextFieldValue = TextFieldValue(""),
|
||||
val scopeTitle: Boolean = false,
|
||||
val scopeContent: Boolean = false,
|
||||
val excludeScope: TextFieldValue = TextFieldValue(""),
|
||||
val timeout: String = "3000",
|
||||
val allGroups: List<String> = emptyList(),
|
||||
val showGroupDialog: Boolean = false
|
||||
)
|
||||
|
||||
fun initData(intent: Intent, finally: (replaceRule: ReplaceRule) -> Unit) {
|
||||
execute {
|
||||
val id = intent.getLongExtra("id", -1)
|
||||
replaceRule = if (id > 0) {
|
||||
appDb.replaceRuleDao.findById(id)
|
||||
class ReplaceEditViewModel(
|
||||
private val app: Application,
|
||||
private val replaceRuleDao: ReplaceRuleDao,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ReplaceEditUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
var activeField: ActiveField = ActiveField.None
|
||||
enum class ActiveField { Name, None, Pattern, Replacement, Scope, Exclude }
|
||||
|
||||
init {
|
||||
initData()
|
||||
observeGroups()
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
viewModelScope.launch {
|
||||
val id = savedStateHandle.get<Long>("id") ?: -1L
|
||||
if (id > 0) {
|
||||
val rule = replaceRuleDao.findById(id)
|
||||
rule?.let { updateStateFromRule(it) }
|
||||
} else {
|
||||
val pattern = intent.getStringExtra("pattern") ?: ""
|
||||
val isRegex = intent.getBooleanExtra("isRegex", false)
|
||||
val scope = intent.getStringExtra("scope")
|
||||
val isScopeTitle = intent.getBooleanExtra("isScopeTitle", false)
|
||||
val isScopeContent = intent.getBooleanExtra("isScopeContent", false)
|
||||
ReplaceRule(
|
||||
name = pattern,
|
||||
pattern = pattern,
|
||||
isRegex = isRegex,
|
||||
scope = scope,
|
||||
scopeTitle = isScopeTitle,
|
||||
scopeContent = isScopeContent
|
||||
)
|
||||
}
|
||||
}.onFinally {
|
||||
replaceRule?.let {
|
||||
finally(it)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
id = -1,
|
||||
name = TextFieldValue(""),
|
||||
pattern = TextFieldValue(savedStateHandle["pattern"] ?: ""),
|
||||
isRegex = savedStateHandle["isRegex"] ?: false,
|
||||
scope = TextFieldValue(savedStateHandle["scope"] ?: ""),
|
||||
scopeTitle = savedStateHandle["isScopeTitle"] ?: false,
|
||||
scopeContent = savedStateHandle["isScopeContent"] ?: false,
|
||||
excludeScope = TextFieldValue(""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pasteRule(success: (ReplaceRule) -> Unit) {
|
||||
execute(context = Dispatchers.Main) {
|
||||
val text = context.getClipText()
|
||||
if (text.isNullOrBlank()) {
|
||||
throw NoStackTraceException("剪贴板为空")
|
||||
private fun observeGroups() {
|
||||
viewModelScope.launch {
|
||||
replaceRuleDao.flowGroups().collectLatest { groups ->
|
||||
_uiState.update { it.copy(allGroups = listOf("默认") + groups) }
|
||||
}
|
||||
GSON.fromJsonObject<ReplaceRule>(text).getOrNull()
|
||||
?: throw NoStackTraceException("格式不对")
|
||||
}.onSuccess {
|
||||
success.invoke(it)
|
||||
}.onError {
|
||||
context.toastOnUi(it.localizedMessage ?: "Error")
|
||||
it.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(replaceRule: ReplaceRule, success: () -> Unit) {
|
||||
execute {
|
||||
replaceRule.checkValid()
|
||||
if (replaceRule.order == Int.MIN_VALUE) {
|
||||
replaceRule.order = appDb.replaceRuleDao.maxOrder + 1
|
||||
}
|
||||
appDb.replaceRuleDao.insert(replaceRule)
|
||||
}.onSuccess {
|
||||
success()
|
||||
}.onError {
|
||||
context.toastOnUi("save error, ${it.localizedMessage}")
|
||||
private fun updateStateFromRule(rule: ReplaceRule) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
id = rule.id,
|
||||
name = TextFieldValue(rule.name),
|
||||
group = rule.group ?: "默认",
|
||||
pattern = TextFieldValue(rule.pattern),
|
||||
replacement = TextFieldValue(rule.replacement),
|
||||
isRegex = rule.isRegex,
|
||||
scopeTitle = rule.scopeTitle,
|
||||
scopeContent = rule.scopeContent,
|
||||
scope = TextFieldValue(rule.scope ?: ""),
|
||||
excludeScope = TextFieldValue(rule.excludeScope ?: ""),
|
||||
timeout = rule.timeoutMillisecond.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
private fun getReplaceRuleFromState(): ReplaceRule {
|
||||
val state = _uiState.value
|
||||
val rule = ReplaceRule().apply {
|
||||
id = state.id
|
||||
name = state.name.text
|
||||
group = if (state.group == "默认" || state.group.isBlank()) null else state.group
|
||||
pattern = state.pattern.text
|
||||
replacement = state.replacement.text
|
||||
isRegex = state.isRegex
|
||||
scopeTitle = state.scopeTitle
|
||||
scopeContent = state.scopeContent
|
||||
scope = state.scope.text
|
||||
excludeScope = state.excludeScope.text
|
||||
timeoutMillisecond = state.timeout.toLongOrNull() ?: 3000L
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
fun copyRule() {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val ruleToCopy = getReplaceRuleFromState()
|
||||
val json = GSON.toJson(ruleToCopy)
|
||||
app.sendToClip(json)
|
||||
app.toastOnUi("规则已复制到剪贴板")
|
||||
}
|
||||
}
|
||||
|
||||
fun pasteRule(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val text = app.getClipText()
|
||||
if (text.isNullOrBlank()) {
|
||||
throw NoStackTraceException("剪贴板为空")
|
||||
}
|
||||
|
||||
val pastedRule = GSON.fromJsonObject<ReplaceRule>(text).getOrNull()
|
||||
?: throw NoStackTraceException("格式不对")
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
updateStateFromRule(pastedRule)
|
||||
onSuccess()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
app.toastOnUi(e.localizedMessage ?: "格式不对")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChange(v: TextFieldValue) {
|
||||
_uiState.update { it.copy(name = v) }
|
||||
activeField = ActiveField.Name
|
||||
}
|
||||
fun onScopeChange(v: TextFieldValue) {
|
||||
_uiState.update { it.copy(scope = v) }
|
||||
activeField = ActiveField.Scope
|
||||
}
|
||||
fun onPatternChange(v: TextFieldValue) {
|
||||
_uiState.update { it.copy(pattern = v) }
|
||||
activeField = ActiveField.Pattern
|
||||
}
|
||||
fun onReplacementChange(v: TextFieldValue) {
|
||||
_uiState.update { it.copy(replacement = v) }
|
||||
activeField = ActiveField.Replacement
|
||||
}
|
||||
fun onExcludeScopeChange(v: TextFieldValue) {
|
||||
_uiState.update { it.copy(excludeScope = v) }
|
||||
activeField = ActiveField.Exclude
|
||||
}
|
||||
fun onGroupChange(v: String) = _uiState.update { it.copy(group = v) }
|
||||
fun onRegexChange(v: Boolean) = _uiState.update { it.copy(isRegex = v) }
|
||||
fun onScopeTitleChange(v: Boolean) = _uiState.update { it.copy(scopeTitle = v) }
|
||||
fun onScopeContentChange(v: Boolean) = _uiState.update { it.copy(scopeContent = v) }
|
||||
fun onTimeoutChange(v: String) = _uiState.update { it.copy(timeout = v) }
|
||||
fun toggleGroupDialog(show: Boolean) = _uiState.update { it.copy(showGroupDialog = show) }
|
||||
fun insertTextAtCursor(text: String) {
|
||||
val state = _uiState.value
|
||||
when (activeField) {
|
||||
ActiveField.Name -> {
|
||||
val newTfv = insertIntoTextFieldValue(state.name, text)
|
||||
_uiState.update { it.copy(name = newTfv) }
|
||||
}
|
||||
ActiveField.Pattern -> {
|
||||
val newTfv = insertIntoTextFieldValue(state.pattern, text)
|
||||
_uiState.update { it.copy(pattern = newTfv) }
|
||||
}
|
||||
ActiveField.Replacement -> {
|
||||
val newTfv = insertIntoTextFieldValue(state.replacement, text)
|
||||
_uiState.update { it.copy(replacement = newTfv) }
|
||||
}
|
||||
ActiveField.Scope -> {
|
||||
val newTfv = insertIntoTextFieldValue(state.scope, text)
|
||||
_uiState.update { it.copy(scope = newTfv) }
|
||||
}
|
||||
ActiveField.Exclude -> {
|
||||
val newTfv = insertIntoTextFieldValue(state.excludeScope, text)
|
||||
_uiState.update { it.copy(excludeScope = newTfv) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertIntoTextFieldValue(current: TextFieldValue, insert: String): TextFieldValue {
|
||||
val start = current.selection.start
|
||||
val end = current.selection.end
|
||||
val newText = current.text.replaceRange(start, end, insert)
|
||||
val newCursor = start + insert.length
|
||||
return TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newCursor)
|
||||
)
|
||||
}
|
||||
|
||||
fun save(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val state = _uiState.value
|
||||
|
||||
val rule = ReplaceRule().apply {
|
||||
id = state.id
|
||||
name = state.name.text
|
||||
group = if (state.group == "默认" || state.group.isBlank()) null else state.group
|
||||
pattern = state.pattern.text
|
||||
replacement = state.replacement.text
|
||||
isRegex = state.isRegex
|
||||
scopeTitle = state.scopeTitle
|
||||
scopeContent = state.scopeContent
|
||||
scope = state.scope.text
|
||||
excludeScope = state.excludeScope.text
|
||||
timeoutMillisecond = state.timeout.toLongOrNull() ?: 3000L
|
||||
}
|
||||
|
||||
if (rule.id <= 0) {
|
||||
rule.order = replaceRuleDao.maxOrder + 1
|
||||
}
|
||||
|
||||
if (rule.order == Int.MIN_VALUE) {
|
||||
rule.order = replaceRuleDao.maxOrder + 1
|
||||
}
|
||||
|
||||
replaceRuleDao.insert(rule)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun deleteGroups(groups: List<String>) {
|
||||
viewModelScope.launch {
|
||||
replaceRuleDao.clearGroups(groups)
|
||||
toggleGroupDialog(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.ContainedLoadingIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
VERSION_MAJOR=3
|
||||
VERSION_MINOR=26
|
||||
VERSION_PATCH=5
|
||||
VERSION_SUFFIX=0
|
||||
VERSION_SUFFIX=1
|
||||
# 1 = Pre, 0 = Release
|
||||
Reference in New Issue
Block a user