fix: 修正规则编辑器状态与更新逻辑

This commit is contained in:
HapeLee
2026-07-01 01:02:56 +08:00
parent 45c403fe79
commit e9eee6b72c
9 changed files with 129 additions and 37 deletions
@@ -47,16 +47,16 @@ interface ReplaceRuleDao {
@Query("SELECT * FROM replace_rules WHERE `group` LIKE :key OR name LIKE :key OR pattern LIKE :key OR replacement LIKE :key OR scope LIKE :key ORDER BY name COLLATE NOCASE DESC")
fun flowSearchNameDesc(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules WHERE `group` LIKE :key ORDER BY sortOrder ASC")
@Query("SELECT * FROM replace_rules WHERE `group` LIKE '%' || :key || '%' ORDER BY sortOrder ASC")
fun flowGroupSearchAsc(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules WHERE `group` LIKE :key ORDER BY sortOrder DESC")
@Query("SELECT * FROM replace_rules WHERE `group` LIKE '%' || :key || '%' ORDER BY sortOrder DESC")
fun flowGroupSearchDesc(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules WHERE `group` LIKE :key ORDER BY name COLLATE NOCASE ASC")
@Query("SELECT * FROM replace_rules WHERE `group` LIKE '%' || :key || '%' ORDER BY name COLLATE NOCASE ASC")
fun flowGroupSearchNameAsc(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules WHERE `group` LIKE :key ORDER BY name COLLATE NOCASE DESC")
@Query("SELECT * FROM replace_rules WHERE `group` LIKE '%' || :key || '%' ORDER BY name COLLATE NOCASE DESC")
fun flowGroupSearchNameDesc(key: String): Flow<List<ReplaceRule>>
// === 未分组 ===
@@ -75,7 +75,7 @@ interface ReplaceRuleDao {
@Query("SELECT * FROM replace_rules where `group` like :key or name like :key or pattern like :key or replacement like :key or scope like :key ORDER BY sortOrder ASC")
fun flowSearch(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules where `group` like :key ORDER BY sortOrder ASC")
@Query("SELECT * FROM replace_rules where `group` like '%' || :key || '%' ORDER BY sortOrder ASC")
fun flowGroupSearch(key: String): Flow<List<ReplaceRule>>
@Query("select `group` from replace_rules where `group` is not null and `group` <> ''")
@@ -37,6 +37,12 @@ class ReplaceRuleRepository {
}
}
suspend fun setEnabled(id: Long, enabled: Boolean) {
withContext(Dispatchers.IO) {
appDb.replaceRuleDao.updateEnabled(id, enabled)
}
}
suspend fun insert(vararg rule: ReplaceRule) {
withContext(Dispatchers.IO) {
appDb.replaceRuleDao.insert(*rule)
@@ -125,19 +131,13 @@ class ReplaceRuleRepository {
suspend fun enableByIds(ids: Set<Long>) =
withContext(Dispatchers.IO) {
if (ids.isEmpty()) return@withContext
val rules = appDb.replaceRuleDao.getByIds(ids)
val updated = rules.map { it.copy(isEnabled = true) }
appDb.replaceRuleDao.update(*updated.toTypedArray())
appDb.replaceRuleDao.updateEnabled(ids.toList(), true)
}
suspend fun disableByIds(ids: Set<Long>) =
withContext(Dispatchers.IO) {
if (ids.isEmpty()) return@withContext
val rules = appDb.replaceRuleDao.getByIds(ids)
val updated = rules.map { it.copy(isEnabled = false) }
appDb.replaceRuleDao.update(*updated.toTypedArray())
appDb.replaceRuleDao.updateEnabled(ids.toList(), false)
}
suspend fun deleteByIds(ids: Set<Long>) =
@@ -2,6 +2,7 @@ package io.legado.app.ui.replace
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
object ReplaceRuleRoute : NavKey
@@ -13,5 +14,6 @@ data class ReplaceEditRoute(
val isRegex: Boolean = false,
val scope: String? = null,
val isScopeTitle: Boolean = false,
val isScopeContent: Boolean = false
val isScopeContent: Boolean = false,
val sessionId: String = UUID.randomUUID().toString()
) : NavKey
@@ -144,7 +144,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
entry<ReplaceEditRoute> { route ->
val viewModel: ReplaceEditViewModel = koinViewModel(
key = "replace_edit_${System.identityHashCode(route)}"
key = "replace_edit_${route.sessionId}"
) { parametersOf(route) }
ReplaceEditRouteScreen(
@@ -14,14 +14,39 @@ data class ReplaceRuleItemUi(
val name: String,
val isEnabled: Boolean,
val group: String?,
val rule: ReplaceRule
) : SelectableItem<Long>
val pattern: String,
val replacement: String,
val scope: String?,
val scopeTitle: Boolean,
val scopeContent: Boolean,
val excludeScope: String?,
val isRegex: Boolean,
val timeoutMillisecond: Long,
val order: Int
) : SelectableItem<Long> {
fun toEntity() = ReplaceRule(
id = id,
name = name,
group = group,
pattern = pattern,
replacement = replacement,
scope = scope,
scopeTitle = scopeTitle,
scopeContent = scopeContent,
excludeScope = excludeScope,
isEnabled = isEnabled,
isRegex = isRegex,
timeoutMillisecond = timeoutMillisecond,
order = order
)
}
data class ReplaceRuleUiState(
override val items: List<ReplaceRuleItemUi> = emptyList(),
override val selectedIds: Set<Long> = emptySet(),
override val searchKey: String = "",
val sortMode: String = "desc",
val selectedGroup: String? = null,
val interaction: InteractionState = InteractionState()
) : ListUiState<ReplaceRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode
@@ -44,7 +69,7 @@ sealed interface ReplaceRuleIntent {
data class MoveItem(val from: Int, val to: Int) : ReplaceRuleIntent
data object SaveSortOrder : ReplaceRuleIntent
data class DeleteRule(val rule: ReplaceRule) : ReplaceRuleIntent
data class SetRuleEnabled(val rule: ReplaceRule, val enabled: Boolean) : ReplaceRuleIntent
data class SetRuleEnabled(val id: Long, val enabled: Boolean) : ReplaceRuleIntent
data class CopyRule(val rule: ReplaceRule) : ReplaceRuleIntent
data class ImportSource(val text: String) : ReplaceRuleIntent
data object CancelImport : ReplaceRuleIntent
@@ -26,7 +26,6 @@ import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -127,8 +126,11 @@ fun ReplaceRuleScreen(
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
var showGroupManageSheet by remember { mutableStateOf(false) }
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = remember(groups) { listOf("全部") + groups }
val selectedTabIndex = state.selectedGroup
?.let(tabItems::indexOf)
?.takeIf { it >= 0 }
?: 0
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
onIntent(ReplaceRuleIntent.MoveItem(from.index, to.index))
@@ -231,10 +233,8 @@ fun ReplaceRuleScreen(
}
}
LaunchedEffect(groups) {
val maxIndex = groups.size
if (selectedTabIndex > maxIndex) {
selectedTabIndex = 0
LaunchedEffect(groups, state.selectedGroup) {
if (state.selectedGroup != null && state.selectedGroup !in groups) {
onIntent(ReplaceRuleIntent.SetGroup("全部"))
}
}
@@ -331,7 +331,6 @@ fun ReplaceRuleScreen(
tabTitles = tabItems,
selectedTabIndex = selectedTabIndex,
onTabSelected = { index ->
selectedTabIndex = index
onIntent(ReplaceRuleIntent.SetGroup(tabItems[index]))
}
)
@@ -422,13 +421,13 @@ fun ReplaceRuleScreen(
onIntent(ReplaceRuleIntent.ToggleSelection(ui.id))
},
onEnabledChange = { enabled ->
onIntent(ReplaceRuleIntent.SetRuleEnabled(ui.rule, enabled))
onIntent(ReplaceRuleIntent.SetRuleEnabled(ui.id, enabled))
},
onClickEdit = {
onNavigateToEdit(
ReplaceEditRoute(
id = ui.id,
pattern = ui.rule.pattern
pattern = ui.pattern
)
)
},
@@ -436,15 +435,24 @@ fun ReplaceRuleScreen(
dropdownContent = { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.move_to_top),
onClick = { onIntent(ReplaceRuleIntent.ToTop(ui.rule)); dismiss() }
onClick = {
onIntent(ReplaceRuleIntent.ToTop(ui.toEntity()))
dismiss()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.move_to_bottom),
onClick = { onIntent(ReplaceRuleIntent.ToBottom(ui.rule)); dismiss() }
onClick = {
onIntent(ReplaceRuleIntent.ToBottom(ui.toEntity()))
dismiss()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.delete),
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
onClick = {
showDeleteRuleDialog = ui.toEntity()
dismiss()
}
)
}
)
@@ -87,7 +87,7 @@ class ReplaceRuleViewModel(
is ReplaceRuleIntent.MoveItem -> moveItemInList(intent.from, intent.to)
ReplaceRuleIntent.SaveSortOrder -> saveSortOrder()
is ReplaceRuleIntent.DeleteRule -> delete(intent.rule)
is ReplaceRuleIntent.SetRuleEnabled -> update(intent.rule.copy(isEnabled = intent.enabled))
is ReplaceRuleIntent.SetRuleEnabled -> setEnabled(intent.id, intent.enabled)
is ReplaceRuleIntent.CopyRule -> { /* not implemented for ReplaceRule */ }
is ReplaceRuleIntent.ImportSource -> importSource(intent.text)
ReplaceRuleIntent.CancelImport -> cancelImport()
@@ -160,6 +160,7 @@ class ReplaceRuleViewModel(
selectedIds = selectedIds,
searchKey = _searchKey.value,
sortMode = _sortMode.value,
selectedGroup = _group.value,
interaction = InteractionState(
isSearchMode = isSearch,
isUploading = isUploading || (importState is BaseImportUiState.Loading),
@@ -168,8 +169,23 @@ class ReplaceRuleViewModel(
)
}
override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(id, name, isEnabled, group, this)
override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.rule
override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(
id = id,
name = name,
isEnabled = isEnabled,
group = group,
pattern = pattern,
replacement = replacement,
scope = scope,
scopeTitle = scopeTitle,
scopeContent = scopeContent,
excludeScope = excludeScope,
isRegex = isRegex,
timeoutMillisecond = timeoutMillisecond,
order = order
)
override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.toEntity()
override suspend fun generateJson(entities: List<ReplaceRule>): String = GSON.toJson(entities)
@@ -248,13 +264,15 @@ class ReplaceRuleViewModel(
private fun saveSortOrder() {
val currentLocal = _localItems.value ?: return
viewModelScope.launch {
repository.moveOrder(currentLocal.map { it.rule }, _sortMode.value == "desc")
repository.moveOrder(currentLocal.map { it.toEntity() }, _sortMode.value == "desc")
_localItems.value = null
}
}
private fun update(vararg rule: ReplaceRule) = viewModelScope.launch { repository.update(*rule) }
private fun setEnabled(id: Long, enabled: Boolean) =
viewModelScope.launch { repository.setEnabled(id, enabled) }
private fun delete(rule: ReplaceRule) = viewModelScope.launch { repository.delete(rule) }
fun enableSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.enableByIds(ids) }
fun disableSelectionByIds(ids: Set<Long>) =
@@ -47,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
@@ -110,6 +111,11 @@ fun ReplaceEditScreen(
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
var showMenu by remember { mutableStateOf(false) }
val isKeyboardVisible by keyboardAsState()
val focusManager = LocalFocusManager.current
val onSave = {
focusManager.clearFocus(force = true)
onIntent(ReplaceEditIntent.Save)
}
AppScaffold(
modifier = Modifier
@@ -127,7 +133,7 @@ fun ReplaceEditScreen(
exit = fadeOut()
) {
TopBarActionButton(
onClick = { onIntent(ReplaceEditIntent.Save) },
onClick = onSave,
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.action_save)
)
@@ -168,7 +174,7 @@ fun ReplaceEditScreen(
visible = !isKeyboardVisible,
alignment = Alignment.BottomEnd,
),
onClick = { onIntent(ReplaceEditIntent.Save) },
onClick = onSave,
tooltipText = stringResource(R.string.action_save),
icon = Icons.Default.Save
)
@@ -0,0 +1,33 @@
package io.legado.app.ui.replace
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ReplaceRuleStateTest {
@Test
fun editedRuleContentChangesListItemEquality() {
val item = ReplaceRuleItemUi(
id = 1L,
name = "rule",
isEnabled = true,
group = null,
pattern = "before",
replacement = "",
scope = null,
scopeTitle = false,
scopeContent = true,
excludeScope = null,
isRegex = true,
timeoutMillisecond = 3000L,
order = 1
)
assertNotEquals(item, item.copy(pattern = "after"))
}
@Test
fun newEditRoutesUseDifferentSessions() {
assertNotEquals(ReplaceEditRoute(), ReplaceEditRoute())
}
}