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") @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>> 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>> 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>> 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>> 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>> 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") @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>> 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>> fun flowGroupSearch(key: String): Flow<List<ReplaceRule>>
@Query("select `group` from replace_rules where `group` is not null and `group` <> ''") @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) { suspend fun insert(vararg rule: ReplaceRule) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
appDb.replaceRuleDao.insert(*rule) appDb.replaceRuleDao.insert(*rule)
@@ -125,19 +131,13 @@ class ReplaceRuleRepository {
suspend fun enableByIds(ids: Set<Long>) = suspend fun enableByIds(ids: Set<Long>) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (ids.isEmpty()) return@withContext if (ids.isEmpty()) return@withContext
appDb.replaceRuleDao.updateEnabled(ids.toList(), true)
val rules = appDb.replaceRuleDao.getByIds(ids)
val updated = rules.map { it.copy(isEnabled = true) }
appDb.replaceRuleDao.update(*updated.toTypedArray())
} }
suspend fun disableByIds(ids: Set<Long>) = suspend fun disableByIds(ids: Set<Long>) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (ids.isEmpty()) return@withContext if (ids.isEmpty()) return@withContext
appDb.replaceRuleDao.updateEnabled(ids.toList(), false)
val rules = appDb.replaceRuleDao.getByIds(ids)
val updated = rules.map { it.copy(isEnabled = false) }
appDb.replaceRuleDao.update(*updated.toTypedArray())
} }
suspend fun deleteByIds(ids: Set<Long>) = suspend fun deleteByIds(ids: Set<Long>) =
@@ -2,6 +2,7 @@ package io.legado.app.ui.replace
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable @Serializable
object ReplaceRuleRoute : NavKey object ReplaceRuleRoute : NavKey
@@ -13,5 +14,6 @@ data class ReplaceEditRoute(
val isRegex: Boolean = false, val isRegex: Boolean = false,
val scope: String? = null, val scope: String? = null,
val isScopeTitle: Boolean = false, val isScopeTitle: Boolean = false,
val isScopeContent: Boolean = false val isScopeContent: Boolean = false,
val sessionId: String = UUID.randomUUID().toString()
) : NavKey ) : NavKey
@@ -144,7 +144,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
entry<ReplaceEditRoute> { route -> entry<ReplaceEditRoute> { route ->
val viewModel: ReplaceEditViewModel = koinViewModel( val viewModel: ReplaceEditViewModel = koinViewModel(
key = "replace_edit_${System.identityHashCode(route)}" key = "replace_edit_${route.sessionId}"
) { parametersOf(route) } ) { parametersOf(route) }
ReplaceEditRouteScreen( ReplaceEditRouteScreen(
@@ -14,14 +14,39 @@ data class ReplaceRuleItemUi(
val name: String, val name: String,
val isEnabled: Boolean, val isEnabled: Boolean,
val group: String?, val group: String?,
val rule: ReplaceRule val pattern: String,
) : SelectableItem<Long> 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( data class ReplaceRuleUiState(
override val items: List<ReplaceRuleItemUi> = emptyList(), override val items: List<ReplaceRuleItemUi> = emptyList(),
override val selectedIds: Set<Long> = emptySet(), override val selectedIds: Set<Long> = emptySet(),
override val searchKey: String = "", override val searchKey: String = "",
val sortMode: String = "desc", val sortMode: String = "desc",
val selectedGroup: String? = null,
val interaction: InteractionState = InteractionState() val interaction: InteractionState = InteractionState()
) : ListUiState<ReplaceRuleItemUi> { ) : ListUiState<ReplaceRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode 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 class MoveItem(val from: Int, val to: Int) : ReplaceRuleIntent
data object SaveSortOrder : ReplaceRuleIntent data object SaveSortOrder : ReplaceRuleIntent
data class DeleteRule(val rule: ReplaceRule) : 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 CopyRule(val rule: ReplaceRule) : ReplaceRuleIntent
data class ImportSource(val text: String) : ReplaceRuleIntent data class ImportSource(val text: String) : ReplaceRuleIntent
data object CancelImport : ReplaceRuleIntent data object CancelImport : ReplaceRuleIntent
@@ -26,7 +26,6 @@ import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -127,8 +126,11 @@ fun ReplaceRuleScreen(
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) } var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
var showGroupManageSheet by remember { mutableStateOf(false) } var showGroupManageSheet by remember { mutableStateOf(false) }
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = remember(groups) { listOf("全部") + groups } val tabItems = remember(groups) { listOf("全部") + groups }
val selectedTabIndex = state.selectedGroup
?.let(tabItems::indexOf)
?.takeIf { it >= 0 }
?: 0
val reorderableState = rememberReorderableLazyListState(listState) { from, to -> val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
onIntent(ReplaceRuleIntent.MoveItem(from.index, to.index)) onIntent(ReplaceRuleIntent.MoveItem(from.index, to.index))
@@ -231,10 +233,8 @@ fun ReplaceRuleScreen(
} }
} }
LaunchedEffect(groups) { LaunchedEffect(groups, state.selectedGroup) {
val maxIndex = groups.size if (state.selectedGroup != null && state.selectedGroup !in groups) {
if (selectedTabIndex > maxIndex) {
selectedTabIndex = 0
onIntent(ReplaceRuleIntent.SetGroup("全部")) onIntent(ReplaceRuleIntent.SetGroup("全部"))
} }
} }
@@ -331,7 +331,6 @@ fun ReplaceRuleScreen(
tabTitles = tabItems, tabTitles = tabItems,
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
onTabSelected = { index -> onTabSelected = { index ->
selectedTabIndex = index
onIntent(ReplaceRuleIntent.SetGroup(tabItems[index])) onIntent(ReplaceRuleIntent.SetGroup(tabItems[index]))
} }
) )
@@ -422,13 +421,13 @@ fun ReplaceRuleScreen(
onIntent(ReplaceRuleIntent.ToggleSelection(ui.id)) onIntent(ReplaceRuleIntent.ToggleSelection(ui.id))
}, },
onEnabledChange = { enabled -> onEnabledChange = { enabled ->
onIntent(ReplaceRuleIntent.SetRuleEnabled(ui.rule, enabled)) onIntent(ReplaceRuleIntent.SetRuleEnabled(ui.id, enabled))
}, },
onClickEdit = { onClickEdit = {
onNavigateToEdit( onNavigateToEdit(
ReplaceEditRoute( ReplaceEditRoute(
id = ui.id, id = ui.id,
pattern = ui.rule.pattern pattern = ui.pattern
) )
) )
}, },
@@ -436,15 +435,24 @@ fun ReplaceRuleScreen(
dropdownContent = { dismiss -> dropdownContent = { dismiss ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.move_to_top), text = stringResource(R.string.move_to_top),
onClick = { onIntent(ReplaceRuleIntent.ToTop(ui.rule)); dismiss() } onClick = {
onIntent(ReplaceRuleIntent.ToTop(ui.toEntity()))
dismiss()
}
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.move_to_bottom), text = stringResource(R.string.move_to_bottom),
onClick = { onIntent(ReplaceRuleIntent.ToBottom(ui.rule)); dismiss() } onClick = {
onIntent(ReplaceRuleIntent.ToBottom(ui.toEntity()))
dismiss()
}
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.delete), 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) is ReplaceRuleIntent.MoveItem -> moveItemInList(intent.from, intent.to)
ReplaceRuleIntent.SaveSortOrder -> saveSortOrder() ReplaceRuleIntent.SaveSortOrder -> saveSortOrder()
is ReplaceRuleIntent.DeleteRule -> delete(intent.rule) 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.CopyRule -> { /* not implemented for ReplaceRule */ }
is ReplaceRuleIntent.ImportSource -> importSource(intent.text) is ReplaceRuleIntent.ImportSource -> importSource(intent.text)
ReplaceRuleIntent.CancelImport -> cancelImport() ReplaceRuleIntent.CancelImport -> cancelImport()
@@ -160,6 +160,7 @@ class ReplaceRuleViewModel(
selectedIds = selectedIds, selectedIds = selectedIds,
searchKey = _searchKey.value, searchKey = _searchKey.value,
sortMode = _sortMode.value, sortMode = _sortMode.value,
selectedGroup = _group.value,
interaction = InteractionState( interaction = InteractionState(
isSearchMode = isSearch, isSearchMode = isSearch,
isUploading = isUploading || (importState is BaseImportUiState.Loading), isUploading = isUploading || (importState is BaseImportUiState.Loading),
@@ -168,8 +169,23 @@ class ReplaceRuleViewModel(
) )
} }
override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(id, name, isEnabled, group, this) override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(
override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.rule 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) override suspend fun generateJson(entities: List<ReplaceRule>): String = GSON.toJson(entities)
@@ -248,13 +264,15 @@ class ReplaceRuleViewModel(
private fun saveSortOrder() { private fun saveSortOrder() {
val currentLocal = _localItems.value ?: return val currentLocal = _localItems.value ?: return
viewModelScope.launch { viewModelScope.launch {
repository.moveOrder(currentLocal.map { it.rule }, _sortMode.value == "desc") repository.moveOrder(currentLocal.map { it.toEntity() }, _sortMode.value == "desc")
_localItems.value = null _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) } private fun delete(rule: ReplaceRule) = viewModelScope.launch { repository.delete(rule) }
fun enableSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.enableByIds(ids) } fun enableSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.enableByIds(ids) }
fun disableSelectionByIds(ids: Set<Long>) = fun disableSelectionByIds(ids: Set<Long>) =
@@ -47,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@@ -110,6 +111,11 @@ fun ReplaceEditScreen(
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val isKeyboardVisible by keyboardAsState() val isKeyboardVisible by keyboardAsState()
val focusManager = LocalFocusManager.current
val onSave = {
focusManager.clearFocus(force = true)
onIntent(ReplaceEditIntent.Save)
}
AppScaffold( AppScaffold(
modifier = Modifier modifier = Modifier
@@ -127,7 +133,7 @@ fun ReplaceEditScreen(
exit = fadeOut() exit = fadeOut()
) { ) {
TopBarActionButton( TopBarActionButton(
onClick = { onIntent(ReplaceEditIntent.Save) }, onClick = onSave,
imageVector = Icons.Default.Save, imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.action_save) contentDescription = stringResource(R.string.action_save)
) )
@@ -168,7 +174,7 @@ fun ReplaceEditScreen(
visible = !isKeyboardVisible, visible = !isKeyboardVisible,
alignment = Alignment.BottomEnd, alignment = Alignment.BottomEnd,
), ),
onClick = { onIntent(ReplaceEditIntent.Save) }, onClick = onSave,
tooltipText = stringResource(R.string.action_save), tooltipText = stringResource(R.string.action_save),
icon = Icons.Default.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())
}
}