[修复] 替换净化编辑或修改时无法正确更新正文的问题 #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.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room) alias(libs.plugins.room)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.google.services) alias(libs.plugins.google.services)
@@ -181,6 +182,7 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.bundles.androidTest) androidTestImplementation(libs.bundles.androidTest)
implementation(libs.kotlin.stdlib) implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.coroutines) implementation(libs.bundles.coroutines)
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.appcompat.appcompat) implementation(libs.appcompat.appcompat)
@@ -256,6 +258,11 @@ dependencies {
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.compose.ui.viewbinding) 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.navigation.compose)
implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material)
implementation(libs.compose.materialIcons) implementation(libs.compose.materialIcons)
-7
View File
@@ -211,13 +211,6 @@
android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize|stateHidden" /> 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 <activity
android:name=".ui.config.ConfigActivity" 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.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.main.my.MyViewModel 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.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
@@ -34,7 +35,13 @@ val appModule = module {
single { SearchContentRepository() } single { SearchContentRepository() }
viewModel { ReadRecordViewModel(get(), get()) } viewModel { ReadRecordViewModel(get(), get()) }
viewModel { ReplaceEditViewModel(get(), get(), get()) } viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
app = get(),
replaceRuleDao = get(),
savedStateHandle = get()
)
}
viewModel { ReplaceRuleViewModel(androidApplication()) } viewModel { ReplaceRuleViewModel(androidApplication()) }
viewModel { ExploreShowViewModel(get()) } viewModel { ExploreShowViewModel(get()) }
viewModel { SearchContentViewModel(get(), 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.dialogs.alert
//import io.legado.app.lib.theme.primaryColor //import io.legado.app.lib.theme.primaryColor
import io.legado.app.model.ReadBook 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.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -95,7 +96,8 @@ class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view
showChineseConvertAlert() showChineseConvertAlert()
return@let 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.browser.WebViewActivity
import io.legado.app.ui.dict.DictDialog import io.legado.app.ui.dict.DictDialog
import io.legado.app.ui.login.SourceLoginActivity 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.ReplaceRuleActivity
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.PopupAction import io.legado.app.ui.widget.PopupAction
import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.ui.widget.dialog.PhotoDialog
import io.legado.app.utils.Debounce import io.legado.app.utils.Debounce
@@ -981,20 +981,23 @@ class ReadBookActivity : BaseReadBookActivity(),
R.id.menu_replace -> { R.id.menu_replace -> {
val scopes = arrayListOf<String>() val scopes = arrayListOf<String>()
ReadBook.book?.name?.let { ReadBook.book?.name?.let { scopes.add(it) }
scopes.add(it) ReadBook.bookSource?.bookSourceUrl?.let { scopes.add(it) }
}
ReadBook.bookSource?.bookSourceUrl?.let {
scopes.add(it)
}
val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n") 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( replaceActivity.launch(
ReplaceEditActivity.startIntent( ReplaceRuleActivity.startIntent(
this, context = this,
pattern = text, editRoute = editRoute
scope = scopes.joinToString(";"),
isScopeTitle = false,
isScopeContent = true
) )
) )
return true return true
@@ -14,13 +14,11 @@ import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter import androidx.fragment.app.FragmentPagerAdapter
@@ -29,7 +27,6 @@ import io.legado.app.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.data.entities.Book 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.ActivityChapterListBinding
import io.legado.app.databinding.DialogDownloadChoiceBinding import io.legado.app.databinding.DialogDownloadChoiceBinding
import io.legado.app.help.book.isLocal 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.about.AppLogDialog
import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog
import io.legado.app.ui.file.HandleFileContract 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.ReplaceRuleActivity
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.dialog.WaitDialog 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.gone
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible import io.legado.app.utils.visible
@@ -262,14 +255,17 @@ class TocActivity : VMBaseActivity<ActivityChapterListBinding, TocViewModel>(),
val scopes = arrayListOf<String>() val scopes = arrayListOf<String>()
viewModel.bookData.value?.name?.let { scopes.add(it) } viewModel.bookData.value?.name?.let { scopes.add(it) }
viewModel.bookSource?.bookSourceUrl?.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( replaceActivity.launch(
ReplaceEditActivity.startIntent( ReplaceRuleActivity.startIntent(this, editRoute)
this,
pattern = "text",
scope = scopes.joinToString(";"),
isScopeTitle = true,
isScopeContent = false
)
) )
return true 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 package io.legado.app.ui.replace
import android.os.Bundle import android.content.Context
import androidx.activity.compose.setContent import android.content.Intent
import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.runtime.Composable 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.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() { 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 @Composable
override fun Content() { override fun Content() {
AppTheme { 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
@@ -86,7 +88,6 @@ import com.google.gson.Gson
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.UploadRepository 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.ActionItem
import io.legado.app.ui.widget.components.AnimatedText import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.DraggableSelectionHandler import io.legado.app.ui.widget.components.DraggableSelectionHandler
@@ -111,8 +112,11 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
) )
@Composable @Composable
fun ReplaceRuleScreen( fun ReplaceRuleScreen(
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: ReplaceRuleViewModel = koinViewModel(), viewModel: ReplaceRuleViewModel = koinViewModel(),
onBackClick: () -> Unit onBackClick: () -> Unit,
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
) { ) {
val uploadRepository: UploadRepository = koinInject() val uploadRepository: UploadRepository = koinInject()
@@ -519,16 +523,24 @@ fun ReplaceRuleScreen(
tooltip = { PlainTooltip { Text("Localized description") } }, tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(), state = rememberTooltipState(),
) { ) {
FloatingActionButton( with(sharedTransitionScope) {
modifier = Modifier.animateFloatingActionButton( FloatingActionButton(
visible = !inSelectionMode, modifier = Modifier
alignment = Alignment.BottomEnd, .animateFloatingActionButton(
), visible = !inSelectionMode,
onClick = { alignment = Alignment.BottomEnd,
context.startActivity(ReplaceEditActivity.startIntent(context)) )
.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) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(rules, key = { it.id }) { ui -> items(rules, key = { it.id }) { ui ->
ReorderableSelectionItem( with(sharedTransitionScope) {
state = reorderableState, ReorderableSelectionItem(
key = ui.id, state = reorderableState,
title = ui.name, key = ui.id,
isEnabled = ui.isEnabled, title = ui.name,
isSelected = selectedRuleIds.contains(ui.id), isEnabled = ui.isEnabled,
inSelectionMode = inSelectionMode, isSelected = selectedRuleIds.contains(ui.id),
canReorder = canReorder, inSelectionMode = inSelectionMode,
onToggleSelection = { canReorder = canReorder,
viewModel.toggleSelection(ui.id) onToggleSelection = {
}, viewModel.toggleSelection(ui.id)
onEnabledChange = { enabled -> },
viewModel.update(ui.rule.copy(isEnabled = enabled)) onEnabledChange = { enabled ->
}, viewModel.update(ui.rule.copy(isEnabled = enabled))
onClickEdit = { },
context.startActivity( onClickEdit = {
ReplaceEditActivity.startIntent( onNavigateToEdit(
context, ReplaceEditRoute(
ui.id id = ui.id,
pattern = ui.rule.pattern
)
) )
) },
}, modifier = Modifier
modifier = Modifier.padding(horizontal = 12.dp), .padding(horizontal = 12.dp)
dropdownContent = { dismiss -> .sharedBounds(
DropdownMenuItem( sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
text = { Text("移至顶部") }, animatedVisibilityScope = animatedVisibilityScope,
onClick = { viewModel.toTop(ui.rule); dismiss() } resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
) //clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
DropdownMenuItem( ),
text = { Text("移至底部") }, dropdownContent = { dismiss ->
onClick = { viewModel.toBottom(ui.rule); dismiss() } DropdownMenuItem(
) text = { Text("移至顶部") },
DropdownMenuItem( onClick = { viewModel.toTop(ui.rule); dismiss() }
text = { Text("删除") }, )
onClick = { showDeleteRuleDialog = ui.rule; dismiss() } DropdownMenuItem(
) text = { Text("移至底部") },
} onClick = { viewModel.toBottom(ui.rule); dismiss() }
) )
DropdownMenuItem(
text = { Text("删除") },
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
)
}
)
}
} }
} }
if (inSelectionMode) { 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 package io.legado.app.ui.replace.edit
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -82,225 +84,267 @@ fun keyboardAsState(): State<Boolean> {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ReplaceEditScreen( fun ReplaceEditScreen(
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onBack: () -> Unit, onBack: () -> Unit,
onSaveSuccess: () -> Unit, onSaveSuccess: () -> Unit,
onShowHelp: (String) -> Unit, onShowHelp: (String) -> Unit,
viewModel: ReplaceEditViewModel = koinViewModel() viewModel: ReplaceEditViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() 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() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val isKeyboardVisible by keyboardAsState() 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") }) { with(sharedTransitionScope) {
Icon(Icons.AutoMirrored.Filled.HelpOutline, contentDescription = "帮助") Box(modifier = Modifier.fillMaxSize()) {
} Scaffold(
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 modifier = Modifier
.padding(paddingValues) .nestedScroll(scrollBehavior.nestedScrollConnection)
.fillMaxSize() .sharedBounds(
.verticalScroll(rememberScrollState()) sharedContentState = rememberSharedContentState(key = sharedKey),
.imePadding() animatedVisibilityScope = animatedVisibilityScope,
.padding(horizontal = 16.dp, vertical = 8.dp), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
verticalArrangement = Arrangement.spacedBy(16.dp) //clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(16.dp))
) { ),
topBar = {
OutlinedTextField( GlassMediumFlexibleTopAppBar(
value = state.name, title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
onValueChange = viewModel::onNameChange, navigationIcon = {
label = { Text("规则名称") }, IconButton(onClick = onBack) {
modifier = Modifier Icon(
.fillMaxWidth() Icons.AutoMirrored.Filled.ArrowBack,
.onFocusChanged { "Back"
if (it.isFocused) viewModel.activeField = )
ReplaceEditViewModel.ActiveField.Name }
}, },
singleLine = true actions = {
)
GroupSelector( IconButton(onClick = { onShowHelp("regexHelp") }) {
currentGroup = state.group, Icon(
allGroups = state.allGroups, Icons.AutoMirrored.Filled.HelpOutline,
onGroupChange = viewModel::onGroupChange, contentDescription = "帮助"
onManageClick = { viewModel.toggleGroupDialog(true) } )
) }
OutlinedTextField( IconButton(onClick = {
value = state.pattern, viewModel.save(onSaveSuccess)
onValueChange = viewModel::onPatternChange, }) {
label = { Text("匹配规则") }, Icon(Icons.Default.Save, contentDescription = "保存")
placeholder = { Text("输入正则表达式或关键字") }, }
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 modifier = Modifier
.fillMaxWidth() .padding(paddingValues)
.onFocusChanged { .fillMaxSize()
if (it.isFocused) viewModel.activeField = .verticalScroll(rememberScrollState())
ReplaceEditViewModel.ActiveField.Pattern .imePadding()
} .padding(horizontal = 16.dp, vertical = 8.dp),
) verticalArrangement = Arrangement.spacedBy(16.dp)
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, OutlinedTextField(
onClick = { viewModel.onScopeTitleChange(!state.scopeTitle) }, value = state.name,
label = { Text("标题") }, onValueChange = viewModel::onNameChange,
leadingIcon = if (state.scopeTitle) { label = { Text("规则名称") },
{ Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) } modifier = Modifier
} else null .fillMaxWidth()
.onFocusChanged {
if (it.isFocused) viewModel.activeField =
ReplaceEditViewModel.ActiveField.Name
},
singleLine = true
) )
Spacer(Modifier.width(8.dp)) GroupSelector(
currentGroup = state.group,
FilterChip( allGroups = state.allGroups,
selected = state.scopeContent, onGroupChange = viewModel::onGroupChange,
onClick = { viewModel.onScopeContentChange(!state.scopeContent) }, onManageClick = { viewModel.toggleGroupDialog(true) }
label = { Text("内容") },
leadingIcon = if (state.scopeContent) {
{ Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) }
} else null
) )
Spacer(Modifier.weight(1f)) OutlinedTextField(
value = state.pattern,
FilterChip( onValueChange = viewModel::onPatternChange,
selected = state.isRegex, label = { Text("匹配规则") },
onClick = { viewModel.onRegexChange(!state.isRegex) }, placeholder = { Text("输入正则表达式或关键字") },
label = { Text("使用正则") }, modifier = Modifier
leadingIcon = if (state.isRegex) { .fillMaxWidth()
{ Icon(Icons.Default.Check, contentDescription = "正则已启用", Modifier.size(FilterChipDefaults.IconSize)) } .onFocusChanged {
} else null 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( AnimatedVisibility(
value = state.scope, visible = isKeyboardVisible,
onValueChange = viewModel::onScopeChange, enter = slideInVertically(
label = { Text("特定范围") }, initialOffsetY = { fullHeight -> fullHeight },
placeholder = { Text("指定规则适用的范围") }, ),
modifier = Modifier exit = slideOutVertically(
.fillMaxWidth() targetOffsetY = { fullHeight -> fullHeight },
.onFocusChanged { ),
if (it.isFocused) viewModel.activeField = modifier = Modifier
ReplaceEditViewModel.ActiveField.Scope .align(Alignment.BottomCenter)
} .imePadding()
) {
QuickInputBar(
onInsert = { text -> viewModel.insertTextAtCursor(text) }
) )
}
OutlinedTextField(
value = state.excludeScope, if (state.showGroupDialog) {
onValueChange = viewModel::onExcludeScopeChange, ManageGroupDialog(
label = { Text("排除范围") }, groups = state.allGroups.filter { it != "默认" },
placeholder = { Text("指定规则不适用的范围") }, onDismiss = { viewModel.toggleGroupDialog(false) },
modifier = Modifier onDelete = { viewModel.deleteGroups(it) }
.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) @OptIn(ExperimentalMaterial3Api::class)
@@ -6,21 +6,26 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import io.legado.app.data.dao.ReplaceRuleDao import io.legado.app.data.dao.ReplaceRuleDao
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getClipText import io.legado.app.utils.getClipText
import io.legado.app.utils.sendToClip import io.legado.app.utils.sendToClip
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers 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 import kotlinx.coroutines.launch
data class ReplaceEditUiState( data class ReplaceEditUiState(
val id: Long = System.currentTimeMillis(), val id: Long = 0,
val name: TextFieldValue = TextFieldValue(""), val name: TextFieldValue = TextFieldValue(""),
val group: String = "默认", val group: String = "默认",
val pattern: TextFieldValue = TextFieldValue(""), val pattern: TextFieldValue = TextFieldValue(""),
@@ -41,6 +46,8 @@ class ReplaceEditViewModel(
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
private val route = savedStateHandle.toRoute<ReplaceEditRoute>()
private val _uiState = MutableStateFlow(ReplaceEditUiState()) private val _uiState = MutableStateFlow(ReplaceEditUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -54,19 +61,22 @@ class ReplaceEditViewModel(
private fun initData() { private fun initData() {
viewModelScope.launch { viewModelScope.launch {
val id = savedStateHandle.get<Long>("id") ?: -1L
val id = route.id
if (id > 0) { if (id > 0) {
val rule = replaceRuleDao.findById(id) val rule = replaceRuleDao.findById(id)
rule?.let { updateStateFromRule(it) } rule?.let { updateStateFromRule(it) }
} else { } else {
_uiState.update { _uiState.update {
it.copy( it.copy(
id = System.currentTimeMillis(),
name = TextFieldValue(""), name = TextFieldValue(""),
pattern = TextFieldValue(savedStateHandle["pattern"] ?: ""), pattern = TextFieldValue(route.pattern ?: ""),
isRegex = savedStateHandle["isRegex"] ?: false, isRegex = route.isRegex,
scope = TextFieldValue(savedStateHandle["scope"] ?: ""), scope = TextFieldValue(route.scope ?: ""),
scopeTitle = savedStateHandle["isScopeTitle"] ?: false, scopeTitle = route.isScopeTitle,
scopeContent = savedStateHandle["isScopeContent"] ?: false, scopeContent = route.isScopeContent,
excludeScope = TextFieldValue(""), excludeScope = TextFieldValue(""),
) )
} }
+1
View File
@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) 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.ksp) apply false
alias(libs.plugins.google.services) apply false alias(libs.plugins.google.services) apply false
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
+17 -5
View File
@@ -1,16 +1,21 @@
[versions] [versions]
accompanistWebview = "0.36.0" 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" biometric = "1.2.0"
coilCompose = "2.7.0" coilCompose = "2.7.0"
composeBom = "2025.12.01" composeBom = "2026.01.00"
constraintlayoutCompose = "1.1.1" constraintlayoutCompose = "1.1.1"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
datastorePreferences = "1.2.0" datastorePreferences = "1.2.0"
foundation = "1.10.0" foundation = "1.10.1"
kotlin = "2.3.0" kotlin = "2.3.0"
kotlinxSerialization = "1.10.0"
kotlinxCoroutinesAndroid = "1.10.2" kotlinxCoroutinesAndroid = "1.10.2"
kotlinxSerializationJson = "1.10.0"
ksp = "2.3.4" ksp = "2.3.4"
agp = "8.13.2" agp = "8.13.2"
appcompat = "1.7.1" appcompat = "1.7.1"
@@ -37,10 +42,11 @@ jsoupxpath = "2.5.3"
coroutines = "1.10.2" coroutines = "1.10.2"
lifecycleViewmodelCompose = "2.10.0" lifecycleViewmodelCompose = "2.10.0"
liveeventbus = "1.8.14" liveeventbus = "1.8.14"
navigation3 = "1.0.0"
markdownCompose = "0.5.0" markdownCompose = "0.5.0"
markwon = "4.6.2" markwon = "4.6.2"
material = "1.14.0-alpha08" material = "1.14.0-alpha08"
material3 = "1.5.0-alpha11" material3 = "1.5.0-alpha12"
material3IconsExtended = "1.7.8" material3IconsExtended = "1.7.8"
materialKolor = "4.1.0" materialKolor = "4.1.0"
media = "1.7.1" 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-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" } 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-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" }
androidx-compose-material = { module = "androidx.compose.material:material" } 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" } 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-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" } 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" } 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-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" } libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" }
lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", 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" } junit = { module = "junit:junit", version = "4.13.2" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 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-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-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }