[修复] 替换净化编辑或修改时无法正确更新正文的问题 #583

[新增] 替换净化编辑与管理界面使用Navigation处理导航逻辑与优化动画
This commit is contained in:
HapeLee
2026-01-24 05:35:53 +08:00
parent f2dd0c741e
commit 30dac8883e
14 changed files with 482 additions and 352 deletions
+7
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
@@ -181,6 +182,7 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.bundles.androidTest)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.coroutines)
implementation(libs.core.ktx)
implementation(libs.appcompat.appcompat)
@@ -256,6 +258,11 @@ dependencies {
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.compose.ui.viewbinding)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.compose.adaptive)
implementation(libs.androidx.compose.adaptive.layout)
implementation(libs.androidx.compose.adaptive.navigation)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.compose.material)
implementation(libs.compose.materialIcons)
-7
View File
@@ -211,13 +211,6 @@
android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize|stateHidden" />
<!-- 替换规则编辑 -->
<activity
android:name=".ui.replace.edit.ReplaceEditActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize|stateHidden" />
<!-- 配置界面 -->
<activity
android:name=".ui.config.ConfigActivity"
@@ -13,6 +13,7 @@ import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.main.my.MyViewModel
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleViewModel
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
import org.koin.android.ext.koin.androidApplication
@@ -34,7 +35,13 @@ val appModule = module {
single { SearchContentRepository() }
viewModel { ReadRecordViewModel(get(), get()) }
viewModel { ReplaceEditViewModel(get(), get(), get()) }
viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
app = get(),
replaceRuleDao = get(),
savedStateHandle = get()
)
}
viewModel { ReplaceRuleViewModel(androidApplication()) }
viewModel { ExploreShowViewModel(get()) }
viewModel { SearchContentViewModel(get(), get()) }
@@ -20,7 +20,8 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.model.ReadBook
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.utils.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -95,7 +96,8 @@ class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view
showChineseConvertAlert()
return@let
}
editActivity.launch(ReplaceEditActivity.startIntent(requireContext(), item.id))
val editRoute = ReplaceEditRoute(id = item.id, pattern = item.pattern)
ReplaceRuleActivity.startIntent(requireContext(), editRoute)
}
}
}
@@ -102,8 +102,8 @@ import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.dict.DictDialog
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.PopupAction
import io.legado.app.ui.widget.dialog.PhotoDialog
import io.legado.app.utils.Debounce
@@ -981,20 +981,23 @@ class ReadBookActivity : BaseReadBookActivity(),
R.id.menu_replace -> {
val scopes = arrayListOf<String>()
ReadBook.book?.name?.let {
scopes.add(it)
}
ReadBook.bookSource?.bookSourceUrl?.let {
scopes.add(it)
}
ReadBook.book?.name?.let { scopes.add(it) }
ReadBook.bookSource?.bookSourceUrl?.let { scopes.add(it) }
val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n")
val editRoute = ReplaceEditRoute(
id = -1,
pattern = text,
scope = scopes.joinToString(";"),
isScopeTitle = false,
isScopeContent = true
)
replaceActivity.launch(
ReplaceEditActivity.startIntent(
this,
pattern = text,
scope = scopes.joinToString(";"),
isScopeTitle = false,
isScopeContent = true
ReplaceRuleActivity.startIntent(
context = this,
editRoute = editRoute
)
)
return true
@@ -14,13 +14,11 @@ import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
@@ -29,7 +27,6 @@ import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.EventBus
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.databinding.ActivityChapterListBinding
import io.legado.app.databinding.DialogDownloadChoiceBinding
import io.legado.app.help.book.isLocal
@@ -41,15 +38,11 @@ import io.legado.app.model.ReadBook
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.utils.applyTint
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.observeEvent
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
@@ -262,14 +255,17 @@ class TocActivity : VMBaseActivity<ActivityChapterListBinding, TocViewModel>(),
val scopes = arrayListOf<String>()
viewModel.bookData.value?.name?.let { scopes.add(it) }
viewModel.bookSource?.bookSourceUrl?.let { scopes.add(it) }
val editRoute = ReplaceEditRoute(
id = -1,
pattern = "text",
scope = scopes.joinToString(";"),
isScopeTitle = true,
isScopeContent = false
)
replaceActivity.launch(
ReplaceEditActivity.startIntent(
this,
pattern = "text",
scope = scopes.joinToString(";"),
isScopeTitle = true,
isScopeContent = false
)
ReplaceRuleActivity.startIntent(this, editRoute)
)
return true
}
@@ -0,0 +1,16 @@
package io.legado.app.ui.replace
import kotlinx.serialization.Serializable
@Serializable
object ReplaceRuleRoute
@Serializable
data class ReplaceEditRoute(
val id: Long = -1,
val pattern: String? = null,
val isRegex: Boolean = false,
val scope: String? = null,
val isScopeTitle: Boolean = false,
val isScopeContent: Boolean = false
)
@@ -1,23 +1,95 @@
package io.legado.app.ui.replace
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import android.content.Context
import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.runtime.Composable
import io.legado.app.ui.theme.AppTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.replace.edit.ReplaceEditScreen
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
import io.legado.app.ui.theme.AppTheme
import kotlinx.serialization.json.Json
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
class ReplaceRuleActivity : BaseComposeActivity() {
companion object {
const val EXTRA_START_ROUTE = "start_route"
fun startIntent(
context: Context,
editRoute: ReplaceEditRoute? = null
): Intent = Intent(context, ReplaceRuleActivity::class.java).apply {
editRoute?.let {
putExtra(EXTRA_START_ROUTE, Json.encodeToString(it))
}
}
}
@Composable
override fun Content() {
AppTheme {
ReplaceRuleScreen(onBackClick = { finish() })
val navController = rememberNavController()
val context = LocalActivity.current
LaunchedEffect(Unit) {
context?.setResult(RESULT_OK)
}
val startRouteJson = intent.getStringExtra(EXTRA_START_ROUTE)
val initialEditRoute = remember(startRouteJson) {
startRouteJson?.let { Json.decodeFromString<ReplaceEditRoute>(it) }
}
val startDestination: Any = initialEditRoute ?: ReplaceRuleRoute
SharedTransitionLayout {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable<ReplaceRuleRoute> {
ReplaceRuleScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
onBackClick = { finish() },
onNavigateToEdit = { route -> navController.navigate(route) }
)
}
composable<ReplaceEditRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ReplaceEditRoute>()
val viewModel: ReplaceEditViewModel = koinViewModel { parametersOf(route) }
ReplaceEditScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
viewModel = viewModel,
onBack = {
// 如果编辑页是唯一的页面,点击返回应关闭 Activity
if (navController.previousBackStackEntry == null) {
finish()
} else {
navController.popBackStack()
}
},
onSaveSuccess = {
if (navController.previousBackStackEntry == null) finish()
else navController.popBackStack()
},
onShowHelp = { /* ... */ }
)
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
}
@@ -4,6 +4,8 @@ import android.content.ClipData
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@@ -86,7 +88,6 @@ import com.google.gson.Gson
import io.legado.app.R
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.UploadRepository
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.DraggableSelectionHandler
@@ -111,8 +112,11 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
)
@Composable
fun ReplaceRuleScreen(
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: ReplaceRuleViewModel = koinViewModel(),
onBackClick: () -> Unit
onBackClick: () -> Unit,
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
) {
val uploadRepository: UploadRepository = koinInject()
@@ -519,16 +523,24 @@ fun ReplaceRuleScreen(
tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(),
) {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(
visible = !inSelectionMode,
alignment = Alignment.BottomEnd,
),
onClick = {
context.startActivity(ReplaceEditActivity.startIntent(context))
with(sharedTransitionScope) {
FloatingActionButton(
modifier = Modifier
.animateFloatingActionButton(
visible = !inSelectionMode,
alignment = Alignment.BottomEnd,
)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "fab_add"),
animatedVisibilityScope = animatedVisibilityScope,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds
),
onClick = {
onNavigateToEdit(ReplaceEditRoute(id = -1))
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Rule")
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Rule")
}
}
}
@@ -555,44 +567,53 @@ fun ReplaceRuleScreen(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(rules, key = { it.id }) { ui ->
ReorderableSelectionItem(
state = reorderableState,
key = ui.id,
title = ui.name,
isEnabled = ui.isEnabled,
isSelected = selectedRuleIds.contains(ui.id),
inSelectionMode = inSelectionMode,
canReorder = canReorder,
onToggleSelection = {
viewModel.toggleSelection(ui.id)
},
onEnabledChange = { enabled ->
viewModel.update(ui.rule.copy(isEnabled = enabled))
},
onClickEdit = {
context.startActivity(
ReplaceEditActivity.startIntent(
context,
ui.id
with(sharedTransitionScope) {
ReorderableSelectionItem(
state = reorderableState,
key = ui.id,
title = ui.name,
isEnabled = ui.isEnabled,
isSelected = selectedRuleIds.contains(ui.id),
inSelectionMode = inSelectionMode,
canReorder = canReorder,
onToggleSelection = {
viewModel.toggleSelection(ui.id)
},
onEnabledChange = { enabled ->
viewModel.update(ui.rule.copy(isEnabled = enabled))
},
onClickEdit = {
onNavigateToEdit(
ReplaceEditRoute(
id = ui.id,
pattern = ui.rule.pattern
)
)
)
},
modifier = Modifier.padding(horizontal = 12.dp),
dropdownContent = { dismiss ->
DropdownMenuItem(
text = { Text("移至顶部") },
onClick = { viewModel.toTop(ui.rule); dismiss() }
)
DropdownMenuItem(
text = { Text("移至底部") },
onClick = { viewModel.toBottom(ui.rule); dismiss() }
)
DropdownMenuItem(
text = { Text("删除") },
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
)
}
)
},
modifier = Modifier
.padding(horizontal = 12.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
animatedVisibilityScope = animatedVisibilityScope,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
),
dropdownContent = { dismiss ->
DropdownMenuItem(
text = { Text("移至顶部") },
onClick = { viewModel.toTop(ui.rule); dismiss() }
)
DropdownMenuItem(
text = { Text("移至底部") },
onClick = { viewModel.toBottom(ui.rule); dismiss() }
)
DropdownMenuItem(
text = { Text("删除") },
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
)
}
)
}
}
}
if (inSelectionMode) {
@@ -1,54 +0,0 @@
package io.legado.app.ui.replace.edit
import android.content.Context
import android.content.Intent
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import io.legado.app.ui.theme.AppTheme
import io.legado.app.base.BaseComposeActivity
/**
* 编辑替换规则
*/
class ReplaceEditActivity : BaseComposeActivity() {
companion object {
fun startIntent(
context: Context,
id: Long = -1,
pattern: String? = null,
isRegex: Boolean = false,
scope: String? = null,
isScopeTitle: Boolean = false,
isScopeContent: Boolean = false
): Intent = Intent(context, ReplaceEditActivity::class.java).apply {
putExtra("id", id)
putExtra("pattern", pattern)
putExtra("isRegex", isRegex)
putExtra("scope", scope)
putExtra("isScopeTitle", isScopeTitle)
putExtra("isScopeContent", isScopeContent)
}
}
@Composable
override fun Content() {
setContent {
AppTheme {
ReplaceEditScreen(
onBack = { finish() },
onSaveSuccess = {
setResult(RESULT_OK)
finish()
},
onShowHelp = { key -> showHelp(key) }
)
}
}
}
private fun showHelp(key: String) {
}
}
@@ -1,6 +1,8 @@
package io.legado.app.ui.replace.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
@@ -82,225 +84,267 @@ fun keyboardAsState(): State<Boolean> {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ReplaceEditScreen(
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onBack: () -> Unit,
onSaveSuccess: () -> Unit,
onShowHelp: (String) -> Unit,
viewModel: ReplaceEditViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsState()
val sharedKey = remember(state.id) {
if (state.id > 0) "rule_${state.id}" else "fab_add"
}
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 = {
GlassMediumFlexibleTopAppBar(
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(
with(sharedTransitionScope) {
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
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
.nestedScroll(scrollBehavior.nestedScrollConnection)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = sharedKey),
animatedVisibilityScope = animatedVisibilityScope,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(16.dp))
),
topBar = {
GlassMediumFlexibleTopAppBar(
title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
"Back"
)
}
},
singleLine = true
)
actions = {
GroupSelector(
currentGroup = state.group,
allGroups = state.allGroups,
onGroupChange = viewModel::onGroupChange,
onManageClick = { viewModel.toggleGroupDialog(true) }
)
IconButton(onClick = { onShowHelp("regexHelp") }) {
Icon(
Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = "帮助"
)
}
OutlinedTextField(
value = state.pattern,
onValueChange = viewModel::onPatternChange,
label = { Text("匹配规则") },
placeholder = { Text("输入正则表达式或关键字") },
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
.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
.padding(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
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
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("规则名称") },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
if (it.isFocused) viewModel.activeField =
ReplaceEditViewModel.ActiveField.Name
},
singleLine = true
)
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
GroupSelector(
currentGroup = state.group,
allGroups = state.allGroups,
onGroupChange = viewModel::onGroupChange,
onManageClick = { viewModel.toggleGroupDialog(true) }
)
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.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))
}
}
OutlinedTextField(
value = state.scope,
onValueChange = viewModel::onScopeChange,
label = { Text("特定范围") },
placeholder = { Text("指定规则适用的范围") },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
if (it.isFocused) viewModel.activeField =
ReplaceEditViewModel.ActiveField.Scope
}
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) }
)
}
OutlinedTextField(
value = state.excludeScope,
onValueChange = viewModel::onExcludeScopeChange,
label = { Text("排除范围") },
placeholder = { Text("指定规则不适用的范围") },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
if (it.isFocused) viewModel.activeField =
ReplaceEditViewModel.ActiveField.Exclude
}
if (state.showGroupDialog) {
ManageGroupDialog(
groups = state.allGroups.filter { it != "默认" },
onDismiss = { viewModel.toggleGroupDialog(false) },
onDelete = { viewModel.deleteGroups(it) }
)
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)
@@ -6,21 +6,26 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import io.legado.app.data.dao.ReplaceRuleDao
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.exception.NoStackTraceException
import io.legado.app.ui.replace.ReplaceEditRoute
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ReplaceEditUiState(
val id: Long = System.currentTimeMillis(),
val id: Long = 0,
val name: TextFieldValue = TextFieldValue(""),
val group: String = "默认",
val pattern: TextFieldValue = TextFieldValue(""),
@@ -41,6 +46,8 @@ class ReplaceEditViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val route = savedStateHandle.toRoute<ReplaceEditRoute>()
private val _uiState = MutableStateFlow(ReplaceEditUiState())
val uiState = _uiState.asStateFlow()
@@ -54,19 +61,22 @@ class ReplaceEditViewModel(
private fun initData() {
viewModelScope.launch {
val id = savedStateHandle.get<Long>("id") ?: -1L
val id = route.id
if (id > 0) {
val rule = replaceRuleDao.findById(id)
rule?.let { updateStateFromRule(it) }
} else {
_uiState.update {
it.copy(
id = System.currentTimeMillis(),
name = TextFieldValue(""),
pattern = TextFieldValue(savedStateHandle["pattern"] ?: ""),
isRegex = savedStateHandle["isRegex"] ?: false,
scope = TextFieldValue(savedStateHandle["scope"] ?: ""),
scopeTitle = savedStateHandle["isScopeTitle"] ?: false,
scopeContent = savedStateHandle["isScopeContent"] ?: false,
pattern = TextFieldValue(route.pattern ?: ""),
isRegex = route.isRegex,
scope = TextFieldValue(route.scope ?: ""),
scopeTitle = route.isScopeTitle,
scopeContent = route.isScopeContent,
excludeScope = TextFieldValue(""),
)
}
+1
View File
@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.room) apply false
+17 -5
View File
@@ -1,16 +1,21 @@
[versions]
accompanistWebview = "0.36.0"
animation = "1.10.0"
adaptive = "1.3.0-alpha06"
adaptiveLayout = "1.3.0-alpha06"
adaptiveNavigation = "1.3.0-alpha06"
animation = "1.10.1"
biometric = "1.2.0"
coilCompose = "2.7.0"
composeBom = "2025.12.01"
composeBom = "2026.01.00"
constraintlayoutCompose = "1.1.1"
coreSplashscreen = "1.2.0"
datastorePreferences = "1.2.0"
foundation = "1.10.0"
foundation = "1.10.1"
kotlin = "2.3.0"
kotlinxSerialization = "1.10.0"
kotlinxCoroutinesAndroid = "1.10.2"
kotlinxSerializationJson = "1.10.0"
ksp = "2.3.4"
agp = "8.13.2"
appcompat = "1.7.1"
@@ -37,10 +42,11 @@ jsoupxpath = "2.5.3"
coroutines = "1.10.2"
lifecycleViewmodelCompose = "2.10.0"
liveeventbus = "1.8.14"
navigation3 = "1.0.0"
markdownCompose = "0.5.0"
markwon = "4.6.2"
material = "1.14.0-alpha08"
material3 = "1.5.0-alpha11"
material3 = "1.5.0-alpha12"
material3IconsExtended = "1.7.8"
materialKolor = "4.1.0"
media = "1.7.1"
@@ -89,10 +95,15 @@ androidx-annotation = { group = "androidx.annotation", name = "annotation", vers
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
androidx-compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-compose-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" }
androidx-compose-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptiveNavigation" }
androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" }
androidx-compose-material = { module = "androidx.compose.material:material" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
compose-materialIcons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "material3IconsExtended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
@@ -138,6 +149,7 @@ androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" }
hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" }
kotlinx-coroutines-android-v181 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" }
lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
@@ -206,7 +218,7 @@ jsoupxpath = { module = "cn.wanghaomiao:JsoupXpath", version.ref = "jsoupxpath"
junit = { module = "junit:junit", version = "4.13.2" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }