fix:修复词典规则页状态同步问题

This commit is contained in:
HapeLee committed 2026-06-13 12:39:07 +08:00
1 parent 3833ca1f7c
commit ecea5607d7
4 files changed
+200 -71

No files matched your search

@@ -10,7 +10,7 @@ class DictRuleActivity : BaseComposeActivity() {
@Composable @Composable
override fun Content() { override fun Content() {
AppTheme { AppTheme {
DictRuleScreen(onBackClick = { finish() }) DictRuleRouteScreen(onBackClick = { finish() })
} }
} }
@@ -0,0 +1,67 @@
package io.legado.app.ui.dict.rule
import android.net.Uri
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.DictRule
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.list.InteractionState
import io.legado.app.ui.widget.components.list.ListUiState
import io.legado.app.ui.widget.components.list.SelectableItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@Stable
data class DictRuleItemUi(
override val id: String,
val urlRule: String,
val showRule: String,
val isEnabled: Boolean,
val rule: DictRule
) : SelectableItem<String>
@Stable
data class DictRuleUiState(
override val items: ImmutableList<DictRuleItemUi> = persistentListOf(),
override val selectedIds: ImmutableSet<String> = persistentSetOf(),
override val searchKey: String = "",
val interaction: InteractionState = InteractionState()
) : ListUiState<DictRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode
override val isLoading: Boolean get() = interaction.isUploading
}
sealed interface DictRuleIntent {
data class SetSearchMode(val active: Boolean) : DictRuleIntent
data class UpdateSearchQuery(val query: String) : DictRuleIntent
data object ClearSelection : DictRuleIntent
data object SelectAll : DictRuleIntent
data object InvertSelection : DictRuleIntent
data class SetSelection(val ids: Set<String>) : DictRuleIntent
data class ToggleSelection(val id: String) : DictRuleIntent
data object EnableSelection : DictRuleIntent
data object DisableSelection : DictRuleIntent
data object DeleteSelection : DictRuleIntent
data object UploadSelection : DictRuleIntent
data class ExportSelection(val uri: Uri) : DictRuleIntent
data class MoveItem(val from: Int, val to: Int) : DictRuleIntent
data object SaveSortOrder : DictRuleIntent
data class SaveRule(val rule: DictRule, val isNew: Boolean) : DictRuleIntent
data class DeleteRule(val rule: DictRule) : DictRuleIntent
data class SetRuleEnabled(val rule: DictRule, val enabled: Boolean) : DictRuleIntent
data class CopyRule(val rule: DictRule) : DictRuleIntent
data class ImportSource(val text: String) : DictRuleIntent
data object CancelImport : DictRuleIntent
data class ToggleImportSelection(val index: Int) : DictRuleIntent
data class ToggleImportAll(val isSelected: Boolean) : DictRuleIntent
data class UpdateImportItem(val index: Int, val rule: DictRule) : DictRuleIntent
data object SaveImportedRules : DictRuleIntent
}
sealed interface DictRuleEffect
data class DictRuleRenderState(
val uiState: DictRuleUiState,
val importState: BaseImportUiState<DictRule>,
)
@@ -16,7 +16,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -43,27 +42,51 @@ import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleEditFields import io.legado.app.ui.widget.components.rules.RuleEditFields
import io.legado.app.ui.widget.components.rules.RuleEditSheet import io.legado.app.ui.widget.components.rules.RuleEditSheet
import io.legado.app.ui.widget.components.rules.RuleListScaffold import io.legado.app.ui.widget.components.rules.RuleListScaffold
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun DictRuleScreen( fun DictRuleRouteScreen(
viewModel: DictRuleViewModel = koinViewModel(), viewModel: DictRuleViewModel = koinViewModel(),
onBackClick: () -> Unit onBackClick: () -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val importState by viewModel.importState.collectAsStateWithLifecycle()
DictRuleScreen(
state = uiState,
importState = importState,
events = viewModel.events,
onIntent = viewModel::onIntent,
onPasteRule = viewModel::pasteRule,
onBackClick = onBackClick,
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DictRuleScreen(
state: DictRuleUiState,
importState: BaseImportUiState<DictRule>,
events: Flow<BaseRuleEvent>,
onIntent: (DictRuleIntent) -> Unit,
onPasteRule: () -> DictRule?,
onBackClick: () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val rules = uiState.items val rules = state.items
val selectedIds = uiState.selectedIds val selectedIds = state.selectedIds
val inSelectionMode = selectedIds.isNotEmpty() val inSelectionMode = selectedIds.isNotEmpty()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -79,16 +102,15 @@ fun DictRuleScreen(
val reorderableState = rememberReorderableLazyListState(listState) { from, to -> val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
viewModel.moveItemInList(from.index, to.index) onIntent(DictRuleIntent.MoveItem(from.index, to.index))
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
} }
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val importState by viewModel.importState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.events.collect { event -> events.collect { event ->
when (event) { when (event) {
is BaseRuleEvent.ShowSnackbar -> { is BaseRuleEvent.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
@@ -117,7 +139,7 @@ fun DictRuleScreen(
uri?.let { uri?.let {
context.contentResolver.openInputStream(it)?.use { stream -> context.contentResolver.openInputStream(it)?.use { stream ->
val text = stream.reader().readText() val text = stream.reader().readText()
viewModel.importSource(text) onIntent(DictRuleIntent.ImportSource(text))
} }
} }
} }
@@ -126,7 +148,7 @@ fun DictRuleScreen(
val exportDoc = rememberLauncherForActivityResult( val exportDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"), contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri -> onResult = { uri ->
uri?.let { viewModel.exportToUri(it, rules, selectedIds) } uri?.let { onIntent(DictRuleIntent.ExportSelection(it)) }
} }
) )
@@ -136,7 +158,7 @@ fun DictRuleScreen(
onDismissRequest = { showUrlInput = false }, onDismissRequest = { showUrlInput = false },
onConfirm = { onConfirm = {
showUrlInput = false showUrlInput = false
viewModel.importSource(it) onIntent(DictRuleIntent.ImportSource(it))
} }
) )
@@ -150,7 +172,7 @@ fun DictRuleScreen(
}, },
onUpload = { onUpload = {
showExportSheet = false showExportSheet = false
viewModel.uploadSelectedRules(selectedIds, rules) onIntent(DictRuleIntent.UploadSelection)
}, },
allowExtensions = arrayOf("json") allowExtensions = arrayOf("json")
) )
@@ -174,11 +196,11 @@ fun DictRuleScreen(
BatchImportDialog( BatchImportDialog(
title = "导入词典规则", title = "导入词典规则",
importState = importState, importState = importState,
onDismissRequest = { viewModel.cancelImport() }, onDismissRequest = { onIntent(DictRuleIntent.CancelImport) },
onToggleItem = { viewModel.toggleImportSelection(it) }, onToggleItem = { onIntent(DictRuleIntent.ToggleImportSelection(it)) },
onToggleAll = { viewModel.toggleImportAll(it) }, onToggleAll = { onIntent(DictRuleIntent.ToggleImportAll(it)) },
onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) }, onUpdateItem = { index, rule -> onIntent(DictRuleIntent.UpdateImportItem(index, rule)) },
onConfirm = { viewModel.saveImportedRules() }, onConfirm = { onIntent(DictRuleIntent.SaveImportedRules) },
itemTitle = { rule -> rule.name }, itemTitle = { rule -> rule.name },
itemSubtitle = { rule -> itemSubtitle = { rule ->
rule.urlRule.takeIf { it.isNotBlank() } rule.urlRule.takeIf { it.isNotBlank() }
@@ -187,7 +209,7 @@ fun DictRuleScreen(
LaunchedEffect(reorderableState.isAnyItemDragging) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
viewModel.saveSortOrder() onIntent(DictRuleIntent.SaveSortOrder)
} }
} }
@@ -197,7 +219,7 @@ fun DictRuleScreen(
title = stringResource(R.string.delete), title = stringResource(R.string.delete),
confirmText = stringResource(R.string.ok), confirmText = stringResource(R.string.ok),
onConfirm = { rule -> onConfirm = { rule ->
viewModel.delete(rule) onIntent(DictRuleIntent.DeleteRule(rule))
showDeleteRuleDialog = null showDeleteRuleDialog = null
}, },
dismissText = stringResource(R.string.cancel), dismissText = stringResource(R.string.cancel),
@@ -215,16 +237,12 @@ fun DictRuleScreen(
editingRule = null editingRule = null
}, },
onSave = { updatedRule -> onSave = { updatedRule ->
if (editingRule == null) { onIntent(DictRuleIntent.SaveRule(updatedRule, isNew = editingRule == null))
viewModel.insert(updatedRule)
} else {
viewModel.update(updatedRule)
}
showEditSheet = false showEditSheet = false
editingRule = null editingRule = null
}, },
onCopy = { viewModel.copyRule(it) }, onCopy = { onIntent(DictRuleIntent.CopyRule(it)) },
onPaste = { viewModel.pasteRule() }, onPaste = onPasteRule,
toFields = { r -> toFields = { r ->
RuleEditFields( RuleEditFields(
name = r?.name ?: "", name = r?.name ?: "",
@@ -247,27 +265,24 @@ fun DictRuleScreen(
RuleListScaffold( RuleListScaffold(
title = "字典规则", title = "字典规则",
state = uiState, state = state,
onBackClick = { onBackClick() }, onBackClick = { onBackClick() },
onSearchToggle = { active -> onSearchToggle = { active ->
viewModel.setSearchMode(active) onIntent(DictRuleIntent.SetSearchMode(active))
}, },
onSearchQueryChange = { viewModel.setSearchKey(it) }, onSearchQueryChange = { onIntent(DictRuleIntent.UpdateSearchQuery(it)) },
searchPlaceholder = stringResource(R.string.replace_purify_search), searchPlaceholder = stringResource(R.string.replace_purify_search),
onClearSelection = { viewModel.setSelection(emptySet()) }, onClearSelection = { onIntent(DictRuleIntent.ClearSelection) },
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) }, onSelectAll = { onIntent(DictRuleIntent.SelectAll) },
onSelectInvert = { onSelectInvert = {
val allIds = rules.map { it.id }.toSet() onIntent(DictRuleIntent.InvertSelection)
viewModel.setSelection(allIds - selectedIds)
}, },
selectionSecondaryActions = listOf( selectionSecondaryActions = listOf(
ActionItem(text = stringResource(R.string.enable), onClick = { ActionItem(text = stringResource(R.string.enable), onClick = {
viewModel.enableSelectionByIds(selectedIds) onIntent(DictRuleIntent.EnableSelection)
viewModel.setSelection(emptySet())
}), }),
ActionItem(text = stringResource(R.string.disable_selection), onClick = { ActionItem(text = stringResource(R.string.disable_selection), onClick = {
viewModel.disableSelectionByIds(selectedIds) onIntent(DictRuleIntent.DisableSelection)
viewModel.setSelection(emptySet())
}), }),
ActionItem( ActionItem(
text = stringResource(R.string.export), text = stringResource(R.string.export),
@@ -275,8 +290,8 @@ fun DictRuleScreen(
), ),
onDeleteSelected = { ids -> onDeleteSelected = { ids ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
viewModel.delSelectionByIds(ids as Set<String>) onIntent(DictRuleIntent.SetSelection(ids as Set<String>))
viewModel.setSelection(emptySet()) onIntent(DictRuleIntent.DeleteSelection)
}, },
onAddClick = { onAddClick = {
editingRule = null editingRule = null
@@ -311,8 +326,10 @@ fun DictRuleScreen(
isEnabled = item.isEnabled, isEnabled = item.isEnabled,
isSelected = selectedIds.contains(item.id), isSelected = selectedIds.contains(item.id),
inSelectionMode = inSelectionMode, inSelectionMode = inSelectionMode,
onToggleSelection = { viewModel.toggleSelection(item.id) }, onToggleSelection = { onIntent(DictRuleIntent.ToggleSelection(item.id)) },
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) }, onEnabledChange = { enabled ->
onIntent(DictRuleIntent.SetRuleEnabled(item.rule, enabled))
},
onClickEdit = { editingRule = item.rule; showEditSheet = true }, onClickEdit = { editingRule = item.rule; showEditSheet = true },
trailingAction = { trailingAction = {
SmallPlainButton( SmallPlainButton(
@@ -328,7 +345,7 @@ fun DictRuleScreen(
listState = listState, listState = listState,
items = rules, items = rules,
selectedIds = selectedIds, selectedIds = selectedIds,
onSelectionChange = { viewModel.setSelection(it) }, onSelectionChange = { onIntent(DictRuleIntent.SetSelection(it)) },
idProvider = { it.id }, idProvider = { it.id },
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
@@ -1,7 +1,6 @@
package io.legado.app.ui.dict.rule package io.legado.app.ui.dict.rule
import android.app.Application import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseRuleViewModel import io.legado.app.base.BaseRuleViewModel
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
@@ -9,8 +8,6 @@ import io.legado.app.data.repository.DictRuleRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.list.InteractionState import io.legado.app.ui.widget.components.list.InteractionState
import io.legado.app.ui.widget.components.list.ListUiState
import io.legado.app.ui.widget.components.list.SelectableItem
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
@@ -19,31 +16,14 @@ import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject import io.legado.app.utils.isJsonObject
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.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Immutable
data class DictRuleItemUi(
override val id: String,
val urlRule: String,
val showRule: String,
val isEnabled: Boolean,
val rule: DictRule
) : SelectableItem<String>
data class DictRuleUiState(
override val items: List<DictRuleItemUi> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "",
val interaction: InteractionState = InteractionState()
) : ListUiState<DictRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode
override val isLoading: Boolean get() = interaction.isUploading
}
class DictRuleViewModel( class DictRuleViewModel(
application: Application, application: Application,
uploadRepository: UploadRepository uploadRepository: UploadRepository
@@ -56,9 +36,65 @@ class DictRuleViewModel(
override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll() override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll()
override fun filterData(data: List<DictRule>, key: String): List<DictRule> { fun onIntent(intent: DictRuleIntent) {
val filtered = if (key.isEmpty()) data when (intent) {
else data.filter { it.name.contains(key, ignoreCase = true) } is DictRuleIntent.SetSearchMode -> setSearchMode(intent.active)
is DictRuleIntent.UpdateSearchQuery -> setSearchKey(intent.query)
DictRuleIntent.ClearSelection -> setSelection(emptySet())
DictRuleIntent.SelectAll -> selectAll()
DictRuleIntent.InvertSelection -> invertSelection()
is DictRuleIntent.SetSelection -> setSelection(intent.ids)
is DictRuleIntent.ToggleSelection -> toggleSelection(intent.id)
DictRuleIntent.EnableSelection -> {
enableSelectionByIds(uiState.value.selectedIds)
setSelection(emptySet())
}
DictRuleIntent.DisableSelection -> {
disableSelectionByIds(uiState.value.selectedIds)
setSelection(emptySet())
}
DictRuleIntent.DeleteSelection -> {
delSelectionByIds(uiState.value.selectedIds)
setSelection(emptySet())
}
DictRuleIntent.UploadSelection -> {
val state = uiState.value
uploadSelectedRules(state.selectedIds, state.items)
}
is DictRuleIntent.ExportSelection -> {
val state = uiState.value
exportToUri(intent.uri, state.items, state.selectedIds)
}
is DictRuleIntent.MoveItem -> moveItemInList(intent.from, intent.to)
DictRuleIntent.SaveSortOrder -> saveSortOrder()
is DictRuleIntent.SaveRule -> {
if (intent.isNew) {
insert(intent.rule)
} else {
update(intent.rule)
}
}
is DictRuleIntent.DeleteRule -> delete(intent.rule)
is DictRuleIntent.SetRuleEnabled -> update(intent.rule.copy(enabled = intent.enabled))
is DictRuleIntent.CopyRule -> copyRule(intent.rule)
is DictRuleIntent.ImportSource -> importSource(intent.text)
DictRuleIntent.CancelImport -> cancelImport()
is DictRuleIntent.ToggleImportSelection -> toggleImportSelection(intent.index)
is DictRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
is DictRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
DictRuleIntent.SaveImportedRules -> saveImportedRules()
}
}
override fun filterData(
data: List<DictRule>,
searchKey: String,
groupFilter: String
): List<DictRule> {
val key = groupFilter.ifEmpty { searchKey }
val filtered = if (key.isEmpty()) data else {
data.filter { it.name.contains(key, ignoreCase = true) }
}
return filtered.sortedBy { it.sortNumber } return filtered.sortedBy { it.sortNumber }
} }
@@ -70,8 +106,8 @@ class DictRuleViewModel(
importState: BaseImportUiState<DictRule> importState: BaseImportUiState<DictRule>
): DictRuleUiState { ): DictRuleUiState {
return DictRuleUiState( return DictRuleUiState(
items = items, items = items.toImmutableList(),
selectedIds = selectedIds, selectedIds = selectedIds.toImmutableSet(),
searchKey = _searchKey.value, searchKey = _searchKey.value,
interaction = InteractionState( interaction = InteractionState(
isSearchMode = isSearch, isSearchMode = isSearch,
@@ -141,6 +177,15 @@ class DictRuleViewModel(
} }
} }
private fun selectAll() {
setSelection(uiState.value.items.map { it.id }.toSet())
}
private fun invertSelection() {
val state = uiState.value
setSelection(state.items.map { it.id }.toSet() - state.selectedIds)
}
fun update(vararg rule: DictRule) = viewModelScope.launch { repository.update(*rule) } fun update(vararg rule: DictRule) = viewModelScope.launch { repository.update(*rule) }
fun insert(vararg rule: DictRule) = viewModelScope.launch { repository.insert(*rule) } fun insert(vararg rule: DictRule) = viewModelScope.launch { repository.insert(*rule) }
fun delete(vararg dictRule: DictRule) = viewModelScope.launch { repository.delete(*dictRule) } fun delete(vararg dictRule: DictRule) = viewModelScope.launch { repository.delete(*dictRule) }
@@ -162,4 +207,4 @@ class DictRuleViewModel(
null null
} }
} }
} }