Ver Fonte

refactor: 重构替换规则界面为MVI架构

HapeLee há 4 meses atrás
pai
commit
da6c02e2ca

+ 3 - 3
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt

@@ -19,7 +19,7 @@ import androidx.navigation3.runtime.entryProvider
 import androidx.navigation3.runtime.rememberNavBackStack
 import androidx.navigation3.ui.NavDisplay
 import io.legado.app.base.BaseComposeActivity
-import io.legado.app.ui.replace.edit.ReplaceEditScreen
+import io.legado.app.ui.replace.edit.ReplaceEditRouteScreen
 import io.legado.app.ui.replace.edit.ReplaceEditViewModel
 import io.legado.app.ui.theme.AppTheme
 import kotlinx.serialization.json.Json
@@ -136,7 +136,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
                 },
                 entryProvider = entryProvider {
                     entry<ReplaceRuleRoute> {
-                        ReplaceRuleScreen(
+                        ReplaceRuleRouteScreen(
                             onBackClick = { finish() },
                             onNavigateToEdit = { route -> backStack.add(route) }
                         )
@@ -147,7 +147,7 @@ class ReplaceRuleActivity : BaseComposeActivity() {
                             key = "replace_edit_${System.identityHashCode(route)}"
                         ) { parametersOf(route) }
 
-                        ReplaceEditScreen(
+                        ReplaceEditRouteScreen(
                             viewModel = viewModel,
                             onBack = {
                                 if (backStack.size > 1) {

+ 67 - 0
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleContract.kt

@@ -0,0 +1,67 @@
+package io.legado.app.ui.replace
+
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import io.legado.app.data.entities.ReplaceRule
+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
+
+@Immutable
+data class ReplaceRuleItemUi(
+    override val id: Long,
+    val name: String,
+    val isEnabled: Boolean,
+    val group: String?,
+    val rule: ReplaceRule
+) : SelectableItem<Long>
+
+data class ReplaceRuleUiState(
+    override val items: List<ReplaceRuleItemUi> = emptyList(),
+    override val selectedIds: Set<Long> = emptySet(),
+    override val searchKey: String = "",
+    val sortMode: String = "desc",
+    val interaction: InteractionState = InteractionState()
+) : ListUiState<ReplaceRuleItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isLoading: Boolean get() = interaction.isUploading
+}
+
+sealed interface ReplaceRuleIntent {
+    data class SetSearchMode(val active: Boolean) : ReplaceRuleIntent
+    data class UpdateSearchQuery(val query: String) : ReplaceRuleIntent
+    data object ClearSelection : ReplaceRuleIntent
+    data object SelectAll : ReplaceRuleIntent
+    data object InvertSelection : ReplaceRuleIntent
+    data class SetSelection(val ids: Set<Long>) : ReplaceRuleIntent
+    data class ToggleSelection(val id: Long) : ReplaceRuleIntent
+    data object EnableSelection : ReplaceRuleIntent
+    data object DisableSelection : ReplaceRuleIntent
+    data object DeleteSelection : ReplaceRuleIntent
+    data object UploadSelection : ReplaceRuleIntent
+    data class ExportSelection(val uri: Uri) : 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 CopyRule(val rule: ReplaceRule) : ReplaceRuleIntent
+    data class ImportSource(val text: String) : ReplaceRuleIntent
+    data object CancelImport : ReplaceRuleIntent
+    data class ToggleImportSelection(val index: Int) : ReplaceRuleIntent
+    data class ToggleImportAll(val isSelected: Boolean) : ReplaceRuleIntent
+    data class UpdateImportItem(val index: Int, val rule: ReplaceRule) : ReplaceRuleIntent
+    data object SaveImportedRules : ReplaceRuleIntent
+    // ReplaceRule-specific
+    data class SetGroup(val groupName: String?) : ReplaceRuleIntent
+    data class SetSortMode(val mode: String) : ReplaceRuleIntent
+    data class ToTop(val rule: ReplaceRule) : ReplaceRuleIntent
+    data class ToBottom(val rule: ReplaceRule) : ReplaceRuleIntent
+    data class TopSelectByIds(val ids: Set<Long>) : ReplaceRuleIntent
+    data class BottomSelectByIds(val ids: Set<Long>) : ReplaceRuleIntent
+    data class AddGroup(val group: String) : ReplaceRuleIntent
+    data class DeleteGroup(val group: String) : ReplaceRuleIntent
+    data class UpGroup(val oldGroup: String, val newGroup: String?) : ReplaceRuleIntent
+}
+
+sealed interface ReplaceRuleEffect

+ 77 - 65
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -6,7 +6,6 @@ import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
@@ -15,7 +14,6 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.FileOpen
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -57,7 +55,6 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
-import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
@@ -66,27 +63,56 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
+@OptIn(
+    ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
     ExperimentalMaterial3ExpressiveApi::class
 )
 @Composable
-fun ReplaceRuleScreen(
+fun ReplaceRuleRouteScreen(
     viewModel: ReplaceRuleViewModel = koinViewModel(),
     onBackClick: () -> Unit,
     onNavigateToEdit: (ReplaceEditRoute) -> Unit,
 ) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val importState by viewModel.importState.collectAsStateWithLifecycle()
+    val groups by viewModel.allGroups.collectAsStateWithLifecycle()
+
+    ReplaceRuleScreen(
+        state = uiState,
+        importState = importState,
+        events = viewModel.events,
+        groups = groups,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        onNavigateToEdit = onNavigateToEdit,
+    )
+}
+
+@OptIn(
+    ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
+    ExperimentalMaterial3ExpressiveApi::class
+)
+@Composable
+fun ReplaceRuleScreen(
+    state: ReplaceRuleUiState,
+    importState: BaseImportUiState<ReplaceRule>,
+    events: Flow<BaseRuleEvent>,
+    groups: List<String>,
+    onIntent: (ReplaceRuleIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onNavigateToEdit: (ReplaceEditRoute) -> Unit,
+) {
 
     val context = LocalContext.current
     val scope = rememberCoroutineScope()
 
-    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    val rules = uiState.items
-    val groups by viewModel.allGroups.collectAsStateWithLifecycle()
-    val selectedIds = uiState.selectedIds
+    val rules = state.items
+    val selectedIds = state.selectedIds
     val inSelectionMode = selectedIds.isNotEmpty()
 
     val listState = rememberLazyListState()
@@ -101,18 +127,16 @@ fun ReplaceRuleScreen(
     var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
     var showGroupManageSheet by remember { mutableStateOf(false) }
 
-    val importState by viewModel.importState.collectAsStateWithLifecycle()
-
     var selectedTabIndex by remember { mutableIntStateOf(0) }
     val tabItems = remember(groups) { listOf("全部") + groups }
 
     val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
-        viewModel.moveItemInList(from.index, to.index)
+        onIntent(ReplaceRuleIntent.MoveItem(from.index, to.index))
         hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
     }
 
-    val canReorder = remember(uiState.sortMode) {
-        uiState.sortMode == "asc" || uiState.sortMode == "desc"
+    val canReorder = remember(state.sortMode) {
+        state.sortMode == "asc" || state.sortMode == "desc"
     }
 
     val importDoc = rememberLauncherForActivityResult(
@@ -121,7 +145,7 @@ fun ReplaceRuleScreen(
             uri?.let {
                 context.contentResolver.openInputStream(it)?.use { stream ->
                     val text = stream.reader().readText()
-                    viewModel.importSource(text)
+                    onIntent(ReplaceRuleIntent.ImportSource(text))
                 }
             }
         }
@@ -130,7 +154,7 @@ fun ReplaceRuleScreen(
     val exportDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.CreateDocument("application/json"),
         onResult = { uri ->
-            uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
+            uri?.let { onIntent(ReplaceRuleIntent.ExportSelection(it)) }
         }
     )
 
@@ -140,7 +164,7 @@ fun ReplaceRuleScreen(
         onDismissRequest = { showUrlInput = false },
         onConfirm = {
             showUrlInput = false
-            viewModel.importSource(it)
+            onIntent(ReplaceRuleIntent.ImportSource(it))
         }
     )
 
@@ -168,7 +192,7 @@ fun ReplaceRuleScreen(
         },
         onUpload = {
             showExportSheet = false
-            viewModel.uploadSelectedRules(selectedIds, rules)
+            onIntent(ReplaceRuleIntent.UploadSelection)
         },
         allowExtensions = arrayOf("json")
     )
@@ -176,11 +200,11 @@ fun ReplaceRuleScreen(
     BatchImportDialog(
         title = stringResource(R.string.import_replace_rule),
         importState = importState,
-        onDismissRequest = { viewModel.cancelImport() },
-        onToggleItem = { viewModel.toggleImportSelection(it) },
-        onToggleAll = { viewModel.toggleImportAll(it) },
-        onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) },
-        onConfirm = { viewModel.saveImportedRules() },
+        onDismissRequest = { onIntent(ReplaceRuleIntent.CancelImport) },
+        onToggleItem = { onIntent(ReplaceRuleIntent.ToggleImportSelection(it)) },
+        onToggleAll = { onIntent(ReplaceRuleIntent.ToggleImportAll(it)) },
+        onUpdateItem = { index, rule -> onIntent(ReplaceRuleIntent.UpdateImportItem(index, rule)) },
+        onConfirm = { onIntent(ReplaceRuleIntent.SaveImportedRules) },
         topBarActions = {},
         itemTitle = { rule -> rule.name },
         itemSubtitle = { rule ->
@@ -189,7 +213,7 @@ fun ReplaceRuleScreen(
     )
 
     if (importState is BaseImportUiState.Loading) {
-        Dialog(onDismissRequest = { viewModel.cancelImport() }) { LoadingIndicator() }
+        Dialog(onDismissRequest = { onIntent(ReplaceRuleIntent.CancelImport) }) { LoadingIndicator() }
     }
 
     LaunchedEffect(importState) {
@@ -197,13 +221,13 @@ fun ReplaceRuleScreen(
             scope.launch {
                 snackbarHostState.showSnackbar(it.msg)
             }
-            viewModel.cancelImport()
+            onIntent(ReplaceRuleIntent.CancelImport)
         }
     }
 
     LaunchedEffect(reorderableState.isAnyItemDragging) {
         if (!reorderableState.isAnyItemDragging) {
-            viewModel.saveSortOrder()
+            onIntent(ReplaceRuleIntent.SaveSortOrder)
         }
     }
 
@@ -211,12 +235,12 @@ fun ReplaceRuleScreen(
         val maxIndex = groups.size
         if (selectedTabIndex > maxIndex) {
             selectedTabIndex = 0
-            viewModel.setGroup("全部")
+            onIntent(ReplaceRuleIntent.SetGroup("全部"))
         }
     }
 
     LaunchedEffect(Unit) {
-        viewModel.events.collect { event ->
+        events.collect { event ->
             when (event) {
                 is BaseRuleEvent.ShowSnackbar -> {
                     val result = snackbarHostState.showSnackbar(
@@ -244,8 +268,8 @@ fun ReplaceRuleScreen(
         show = showGroupManageSheet,
         groups = groups,
         onDismissRequest = { showGroupManageSheet = false },
-        onUpdateGroup = { old, new -> viewModel.upGroup(old, new) },
-        onDeleteGroup = { viewModel.delGroup(it) }
+        onUpdateGroup = { old, new -> onIntent(ReplaceRuleIntent.UpGroup(old, new)) },
+        onDeleteGroup = { onIntent(ReplaceRuleIntent.DeleteGroup(it)) }
     )
 
 
@@ -255,7 +279,7 @@ fun ReplaceRuleScreen(
         title = stringResource(R.string.delete),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
-            viewModel.delete(rule)
+            onIntent(ReplaceRuleIntent.DeleteRule(rule))
             showDeleteRuleDialog = null
         },
         dismissText = stringResource(R.string.cancel),
@@ -264,42 +288,30 @@ fun ReplaceRuleScreen(
 
     RuleListScaffold(
         title = "替换规则",
-        state = uiState,
+        state = state,
         onBackClick = { onBackClick() },
-        onSearchToggle = { viewModel.setSearchMode(!uiState.isSearch) },
-        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        onSearchToggle = { onIntent(ReplaceRuleIntent.SetSearchMode(!state.isSearch)) },
+        onSearchQueryChange = { onIntent(ReplaceRuleIntent.UpdateSearchQuery(it)) },
         searchPlaceholder = stringResource(R.string.replace_purify_search),
-        onClearSelection = { viewModel.setSelection(emptySet()) },
-        onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
-        onSelectInvert = { viewModel.setSelection(rules.map { it.id }.toSet() - selectedIds) },
+        onClearSelection = { onIntent(ReplaceRuleIntent.ClearSelection) },
+        onSelectAll = { onIntent(ReplaceRuleIntent.SelectAll) },
+        onSelectInvert = { onIntent(ReplaceRuleIntent.InvertSelection) },
         selectionSecondaryActions = listOf(
             ActionItem(
                 text = stringResource(R.string.enable),
-                onClick = {
-                    viewModel.enableSelectionByIds(selectedIds)
-                    viewModel.setSelection(emptySet())
-                }
+                onClick = { onIntent(ReplaceRuleIntent.EnableSelection) }
             ),
             ActionItem(
                 text = stringResource(R.string.disable_selection),
-                onClick = {
-                    viewModel.disableSelectionByIds(selectedIds)
-                    viewModel.setSelection(emptySet())
-                }
+                onClick = { onIntent(ReplaceRuleIntent.DisableSelection) }
             ),
             ActionItem(
                 text = stringResource(R.string.to_top),
-                onClick = {
-                    viewModel.topSelectByIds(selectedIds)
-                    viewModel.setSelection(emptySet())
-                }
+                onClick = { onIntent(ReplaceRuleIntent.TopSelectByIds(selectedIds)) }
             ),
             ActionItem(
                 text = stringResource(R.string.to_bottom),
-                onClick = {
-                    viewModel.bottomSelectByIds(selectedIds)
-                    viewModel.setSelection(emptySet())
-                }
+                onClick = { onIntent(ReplaceRuleIntent.BottomSelectByIds(selectedIds)) }
             ),
             ActionItem(
                 text = stringResource(R.string.export),
@@ -308,8 +320,8 @@ fun ReplaceRuleScreen(
         ),
         onDeleteSelected = { ids ->
             @Suppress("UNCHECKED_CAST")
-            viewModel.delSelectionByIds(ids as Set<Long>)
-            viewModel.setSelection(emptySet())
+            onIntent(ReplaceRuleIntent.SetSelection(ids as Set<Long>))
+            onIntent(ReplaceRuleIntent.DeleteSelection)
         },
         bottomContent = {
             if (tabItems.size > 1) {
@@ -320,7 +332,7 @@ fun ReplaceRuleScreen(
                     selectedTabIndex = selectedTabIndex,
                     onTabSelected = { index ->
                         selectedTabIndex = index
-                        viewModel.setGroup(tabItems[index])
+                        onIntent(ReplaceRuleIntent.SetGroup(tabItems[index]))
                     }
                 )
             }
@@ -355,16 +367,16 @@ fun ReplaceRuleScreen(
             PillDivider()
             RoundDropdownMenuItem(
                 text = "旧的在前",
-                onClick = { viewModel.setSortMode("asc"); dismiss() }
+                onClick = { onIntent(ReplaceRuleIntent.SetSortMode("asc")); dismiss() }
             )
             RoundDropdownMenuItem(
                 text = "新的在前",
-                onClick = { viewModel.setSortMode("desc"); dismiss() }
+                onClick = { onIntent(ReplaceRuleIntent.SetSortMode("desc")); dismiss() }
             )
             RoundDropdownMenuItem(
                 text = "名称升序",
                 onClick = {
-                    viewModel.setSortMode("name_asc")
+                    onIntent(ReplaceRuleIntent.SetSortMode("name_asc"))
                     dismiss()
                     scope.launch {
                         snackbarHostState.showSnackbar("当前排序模式下禁用拖动")
@@ -374,7 +386,7 @@ fun ReplaceRuleScreen(
             RoundDropdownMenuItem(
                 text = "名称降序",
                 onClick = {
-                    viewModel.setSortMode("name_desc")
+                    onIntent(ReplaceRuleIntent.SetSortMode("name_desc"))
                     dismiss()
                     scope.launch {
                         snackbarHostState.showSnackbar("当前排序模式下禁用拖动")
@@ -407,10 +419,10 @@ fun ReplaceRuleScreen(
                         inSelectionMode = inSelectionMode,
                         canReorder = canReorder,
                         onToggleSelection = {
-                            viewModel.toggleSelection(ui.id)
+                            onIntent(ReplaceRuleIntent.ToggleSelection(ui.id))
                         },
                         onEnabledChange = { enabled ->
-                            viewModel.update(ui.rule.copy(isEnabled = enabled))
+                            onIntent(ReplaceRuleIntent.SetRuleEnabled(ui.rule, enabled))
                         },
                         onClickEdit = {
                             onNavigateToEdit(
@@ -424,11 +436,11 @@ fun ReplaceRuleScreen(
                         dropdownContent = { dismiss ->
                             RoundDropdownMenuItem(
                                 text = "移至顶部",
-                                onClick = { viewModel.toTop(ui.rule); dismiss() }
+                                onClick = { onIntent(ReplaceRuleIntent.ToTop(ui.rule)); dismiss() }
                             )
                             RoundDropdownMenuItem(
                                 text = "移至底部",
-                                onClick = { viewModel.toBottom(ui.rule); dismiss() }
+                                onClick = { onIntent(ReplaceRuleIntent.ToBottom(ui.rule)); dismiss() }
                             )
                             RoundDropdownMenuItem(
                                 text = "删除",
@@ -443,7 +455,7 @@ fun ReplaceRuleScreen(
                     listState = listState,
                     items = rules,
                     selectedIds = selectedIds,
-                    onSelectionChange = { viewModel.setSelection(it) },
+                    onSelectionChange = { onIntent(ReplaceRuleIntent.SetSelection(it)) },
                     idProvider = { it.id },
                     modifier = Modifier
                         .fillMaxHeight()

+ 77 - 38
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.replace
 
 import android.app.Application
-import androidx.compose.runtime.Immutable
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseRuleEvent
 import io.legado.app.base.BaseRuleViewModel
@@ -14,8 +13,6 @@ import io.legado.app.data.repository.UploadRepository
 import io.legado.app.help.ReplaceAnalyzer
 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 io.legado.app.utils.GSON
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isJsonArray
@@ -37,27 +34,6 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
-@Immutable
-data class ReplaceRuleItemUi(
-    override val id: Long,
-    val name: String,
-    val isEnabled: Boolean,
-    val group: String?,
-    val rule: ReplaceRule
-) : SelectableItem<Long>
-
-data class ReplaceRuleUiState(
-    override val items: List<ReplaceRuleItemUi> = emptyList(),
-    override val selectedIds: Set<Long> = emptySet(),
-    override val searchKey: String = "",
-    val sortMode: String = "desc",
-    val groups: List<String> = emptyList(),
-    val interaction: InteractionState = InteractionState()
-) : ListUiState<ReplaceRuleItemUi> {
-    override val isSearch: Boolean get() = interaction.isSearchMode
-    override val isLoading: Boolean get() = interaction.isUploading
-}
-
 class ReplaceRuleViewModel(
     application: Application,
     uploadRepository: UploadRepository
@@ -68,6 +44,7 @@ class ReplaceRuleViewModel(
 ) {
     private val repository = ReplaceRuleRepository()
     private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
+    val sortMode = _sortMode.asStateFlow()
     private val _group = MutableStateFlow<String?>(null)
     val group = _group.asStateFlow()
 
@@ -78,7 +55,60 @@ class ReplaceRuleViewModel(
             initialValue = emptyList()
         )
 
-    fun setGroup(groupName: String?) {
+    fun onIntent(intent: ReplaceRuleIntent) {
+        when (intent) {
+            is ReplaceRuleIntent.SetSearchMode -> setSearchMode(intent.active)
+            is ReplaceRuleIntent.UpdateSearchQuery -> setSearchKey(intent.query)
+            ReplaceRuleIntent.ClearSelection -> setSelection(emptySet())
+            ReplaceRuleIntent.SelectAll -> selectAll()
+            ReplaceRuleIntent.InvertSelection -> invertSelection()
+            is ReplaceRuleIntent.SetSelection -> setSelection(intent.ids)
+            is ReplaceRuleIntent.ToggleSelection -> toggleSelection(intent.id)
+            ReplaceRuleIntent.EnableSelection -> {
+                enableSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            ReplaceRuleIntent.DisableSelection -> {
+                disableSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            ReplaceRuleIntent.DeleteSelection -> {
+                delSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            ReplaceRuleIntent.UploadSelection -> {
+                val state = uiState.value
+                uploadSelectedRules(state.selectedIds, state.items)
+            }
+            is ReplaceRuleIntent.ExportSelection -> {
+                val state = uiState.value
+                exportToUri(intent.uri, state.items, state.selectedIds)
+            }
+            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.CopyRule -> { /* not implemented for ReplaceRule */ }
+            is ReplaceRuleIntent.ImportSource -> importSource(intent.text)
+            ReplaceRuleIntent.CancelImport -> cancelImport()
+            is ReplaceRuleIntent.ToggleImportSelection -> toggleImportSelection(intent.index)
+            is ReplaceRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
+            is ReplaceRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
+            ReplaceRuleIntent.SaveImportedRules -> saveImportedRules()
+            // ReplaceRule-specific
+            is ReplaceRuleIntent.SetGroup -> setGroup(intent.groupName)
+            is ReplaceRuleIntent.SetSortMode -> setSortMode(intent.mode)
+            is ReplaceRuleIntent.ToTop -> toTop(intent.rule)
+            is ReplaceRuleIntent.ToBottom -> toBottom(intent.rule)
+            is ReplaceRuleIntent.TopSelectByIds -> topSelectByIds(intent.ids)
+            is ReplaceRuleIntent.BottomSelectByIds -> bottomSelectByIds(intent.ids)
+            is ReplaceRuleIntent.AddGroup -> addGroup(intent.group)
+            is ReplaceRuleIntent.DeleteGroup -> delGroup(intent.group)
+            is ReplaceRuleIntent.UpGroup -> upGroup(intent.oldGroup, intent.newGroup)
+        }
+    }
+
+    private fun setGroup(groupName: String?) {
         _group.value = if (groupName == "全部" || groupName.isNullOrBlank()) {
             null
         } else {
@@ -129,6 +159,7 @@ class ReplaceRuleViewModel(
             items = items,
             selectedIds = selectedIds,
             searchKey = _searchKey.value,
+            sortMode = _sortMode.value,
             interaction = InteractionState(
                 isSearchMode = isSearch,
                 isUploading = isUploading || (importState is BaseImportUiState.Loading),
@@ -209,12 +240,12 @@ class ReplaceRuleViewModel(
         return if (comparator != null) rules.sortedWith(comparator) else rules
     }
 
-    fun setSortMode(mode: String) {
+    private fun setSortMode(mode: String) {
         _sortMode.value = mode
         context.putPrefString(PreferKey.replaceSortMode, mode)
     }
 
-    fun saveSortOrder() {
+    private fun saveSortOrder() {
         val currentLocal = _localItems.value ?: return
         viewModelScope.launch {
             repository.moveOrder(currentLocal.map { it.rule }, _sortMode.value == "desc")
@@ -223,8 +254,8 @@ class ReplaceRuleViewModel(
     }
 
 
-    fun update(vararg rule: ReplaceRule) = viewModelScope.launch { repository.update(*rule) }
-    fun delete(rule: ReplaceRule) = viewModelScope.launch { repository.delete(rule) }
+    private fun update(vararg rule: ReplaceRule) = viewModelScope.launch { repository.update(*rule) }
+    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>) =
         viewModelScope.launch { repository.disableByIds(ids) }
@@ -234,22 +265,30 @@ class ReplaceRuleViewModel(
         _selectedIds.update { it - ids }
     }
 
-    fun addGroup(group: String) = viewModelScope.launch { repository.addGroup(group) }
-    fun delGroup(group: String) = viewModelScope.launch { repository.delGroup(group) }
+    private fun selectAll() {
+        setSelection(uiState.value.items.map { it.id }.toSet())
+    }
 
-    fun toTop(rule: ReplaceRule) =
+    private fun invertSelection() {
+        val state = uiState.value
+        setSelection(state.items.map { it.id }.toSet() - state.selectedIds)
+    }
+
+    private fun addGroup(group: String) = viewModelScope.launch { repository.addGroup(group) }
+    private fun delGroup(group: String) = viewModelScope.launch { repository.delGroup(group) }
+
+    private fun toTop(rule: ReplaceRule) =
         viewModelScope.launch { repository.toTop(rule, _sortMode.value == "desc") }
 
-    fun toBottom(rule: ReplaceRule) =
+    private fun toBottom(rule: ReplaceRule) =
         viewModelScope.launch { repository.toBottom(rule, _sortMode.value == "desc") }
 
-    fun upOrder() = viewModelScope.launch { repository.upOrder() }
-    fun topSelectByIds(ids: Set<Long>) =
+    private fun topSelectByIds(ids: Set<Long>) =
         viewModelScope.launch { repository.topByIds(ids, _sortMode.value == "desc") }
 
-    fun bottomSelectByIds(ids: Set<Long>) =
+    private fun bottomSelectByIds(ids: Set<Long>) =
         viewModelScope.launch { repository.bottomByIds(ids, _sortMode.value == "desc") }
 
-    fun upGroup(oldGroup: String, newGroup: String?) =
+    private fun upGroup(oldGroup: String, newGroup: String?) =
         viewModelScope.launch { repository.upGroup(oldGroup, newGroup) }
-}
+}

+ 47 - 0
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditContract.kt

@@ -0,0 +1,47 @@
+package io.legado.app.ui.replace.edit
+
+import androidx.compose.runtime.Stable
+
+@Stable
+data class ReplaceEditUiState(
+    val id: Long = 0,
+    val name: String = "",
+    val group: String = "默认",
+    val pattern: String = "",
+    val replacement: String = "",
+    val isRegex: Boolean = false,
+    val scope: String = "",
+    val scopeTitle: Boolean = false,
+    val scopeContent: Boolean = false,
+    val excludeScope: String = "",
+    val timeout: String = "3000",
+    val allGroups: List<String> = emptyList(),
+    val showGroupDialog: Boolean = false,
+    val activeField: ActiveField = ActiveField.None
+)
+
+enum class ActiveField { Name, None, Pattern, Replacement, Scope, Exclude }
+
+sealed interface ReplaceEditIntent {
+    data class OnNameChange(val value: String) : ReplaceEditIntent
+    data class OnPatternChange(val value: String) : ReplaceEditIntent
+    data class OnReplacementChange(val value: String) : ReplaceEditIntent
+    data class OnScopeChange(val value: String) : ReplaceEditIntent
+    data class OnExcludeScopeChange(val value: String) : ReplaceEditIntent
+    data class OnGroupChange(val value: String) : ReplaceEditIntent
+    data class OnRegexChange(val value: Boolean) : ReplaceEditIntent
+    data class OnScopeTitleChange(val value: Boolean) : ReplaceEditIntent
+    data class OnScopeContentChange(val value: Boolean) : ReplaceEditIntent
+    data class OnTimeoutChange(val value: String) : ReplaceEditIntent
+    data class SetActiveField(val field: ActiveField) : ReplaceEditIntent
+    data class InsertTextAtCursor(val text: String) : ReplaceEditIntent
+    data class ToggleGroupDialog(val show: Boolean) : ReplaceEditIntent
+    data class DeleteGroups(val groups: List<String>) : ReplaceEditIntent
+    data object CopyRule : ReplaceEditIntent
+    data object PasteRule : ReplaceEditIntent
+    data object Save : ReplaceEditIntent
+}
+
+sealed interface ReplaceEditEffect {
+    data object NavigateBack : ReplaceEditEffect
+}

+ 53 - 34
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -35,8 +35,8 @@ import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -65,6 +65,9 @@ import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 
 @Composable
@@ -75,12 +78,35 @@ fun keyboardAsState(): State<Boolean> {
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun ReplaceEditScreen(
+fun ReplaceEditRouteScreen(
+    viewModel: ReplaceEditViewModel = koinViewModel(),
     onBack: () -> Unit,
     onSaveSuccess: () -> Unit,
-    viewModel: ReplaceEditViewModel = koinViewModel()
 ) {
-    val state by viewModel.uiState.collectAsState()
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                ReplaceEditEffect.NavigateBack -> onSaveSuccess()
+            }
+        }
+    }
+
+    ReplaceEditScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBack = onBack,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ReplaceEditScreen(
+    state: ReplaceEditUiState,
+    onIntent: (ReplaceEditIntent) -> Unit,
+    onBack: () -> Unit,
+) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     var showMenu by remember { mutableStateOf(false) }
     val isKeyboardVisible by keyboardAsState()
@@ -101,9 +127,7 @@ fun ReplaceEditScreen(
                         exit = fadeOut()
                     ) {
                         TopBarActionButton(
-                            onClick = {
-                                viewModel.save(onSaveSuccess)
-                            },
+                            onClick = { onIntent(ReplaceEditIntent.Save) },
                             imageVector = Icons.Default.Save,
                             contentDescription = "保存"
                         )
@@ -121,14 +145,14 @@ fun ReplaceEditScreen(
                             text = "复制规则",
                             onClick = {
                                 showMenu = false
-                                viewModel.copyRule()
+                                onIntent(ReplaceEditIntent.CopyRule)
                             }
                         )
                         RoundDropdownMenuItem(
                             text = "粘贴规则",
                             onClick = {
                                 showMenu = false
-                                viewModel.pasteRule(onSuccess = {})
+                                onIntent(ReplaceEditIntent.PasteRule)
                             }
                         )
                     }
@@ -144,7 +168,7 @@ fun ReplaceEditScreen(
                         visible = !isKeyboardVisible,
                         alignment = Alignment.BottomEnd,
                     ),
-                onClick = { viewModel.save(onSaveSuccess) },
+                onClick = { onIntent(ReplaceEditIntent.Save) },
                 tooltipText = stringResource(R.string.action_save),
                 icon = Icons.Default.Save
             )
@@ -168,7 +192,7 @@ fun ReplaceEditScreen(
                     .zIndex(1f)
             ) {
                 QuickInputBar(
-                    onInsert = { text -> viewModel.insertTextAtCursor(text) }
+                    onInsert = { text -> onIntent(ReplaceEditIntent.InsertTextAtCursor(text)) }
                 )
             }
             Column(
@@ -181,13 +205,12 @@ fun ReplaceEditScreen(
 
                 AppTextField(
                     value = state.name,
-                    onValueChange = viewModel::onNameChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnNameChange(it)) },
                     label = "规则名称",
                     modifier = Modifier
                         .fillMaxWidth()
                         .onFocusChanged {
-                            if (it.isFocused) viewModel.activeField =
-                                ReplaceEditViewModel.ActiveField.Name
+                            if (it.isFocused) onIntent(ReplaceEditIntent.SetActiveField(ActiveField.Name))
                         },
                     singleLine = true
                 )
@@ -195,33 +218,31 @@ fun ReplaceEditScreen(
                 GroupSelector(
                     currentGroup = state.group,
                     allGroups = state.allGroups,
-                    onGroupChange = viewModel::onGroupChange,
-                    onManageClick = { viewModel.toggleGroupDialog(true) }
+                    onGroupChange = { onIntent(ReplaceEditIntent.OnGroupChange(it)) },
+                    onManageClick = { onIntent(ReplaceEditIntent.ToggleGroupDialog(true)) }
                 )
 
                 AppTextField(
                     value = state.pattern,
-                    onValueChange = viewModel::onPatternChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnPatternChange(it)) },
                     label = "匹配规则",
                     placeholder = { AppText("输入正则表达式或关键字") },
                     modifier = Modifier
                         .fillMaxWidth()
                         .onFocusChanged {
-                            if (it.isFocused) viewModel.activeField =
-                                ReplaceEditViewModel.ActiveField.Pattern
+                            if (it.isFocused) onIntent(ReplaceEditIntent.SetActiveField(ActiveField.Pattern))
                         }
                 )
 
                 AppTextField(
                     value = state.replacement,
-                    onValueChange = viewModel::onReplacementChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnReplacementChange(it)) },
                     label = "替换为",
                     placeholder = { AppText("输入替换内容或捕获组") },
                     modifier = Modifier
                         .fillMaxWidth()
                         .onFocusChanged {
-                            if (it.isFocused) viewModel.activeField =
-                                ReplaceEditViewModel.ActiveField.Replacement
+                            if (it.isFocused) onIntent(ReplaceEditIntent.SetActiveField(ActiveField.Replacement))
                         }
                 )
 
@@ -234,7 +255,7 @@ fun ReplaceEditScreen(
                         label = "标题",
                         selected = state.scopeTitle,
                         checkedContentDescription = "已选择",
-                        onToggle = { viewModel.onScopeTitleChange(!state.scopeTitle) }
+                        onToggle = { onIntent(ReplaceEditIntent.OnScopeTitleChange(!state.scopeTitle)) }
                     )
 
                     Spacer(Modifier.width(8.dp))
@@ -243,7 +264,7 @@ fun ReplaceEditScreen(
                         label = "内容",
                         selected = state.scopeContent,
                         checkedContentDescription = "已选择",
-                        onToggle = { viewModel.onScopeContentChange(!state.scopeContent) }
+                        onToggle = { onIntent(ReplaceEditIntent.OnScopeContentChange(!state.scopeContent)) }
                     )
 
                     Spacer(Modifier.weight(1f))
@@ -252,40 +273,38 @@ fun ReplaceEditScreen(
                         label = "使用正则",
                         selected = state.isRegex,
                         checkedContentDescription = "正则已启用",
-                        onToggle = { viewModel.onRegexChange(!state.isRegex) }
+                        onToggle = { onIntent(ReplaceEditIntent.OnRegexChange(!state.isRegex)) }
                     )
 
                 }
 
                 AppTextField(
                     value = state.scope,
-                    onValueChange = viewModel::onScopeChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnScopeChange(it)) },
                     label = "特定范围",
                     placeholder = { AppText("指定规则适用的范围") },
                     modifier = Modifier
                         .fillMaxWidth()
                         .onFocusChanged {
-                            if (it.isFocused) viewModel.activeField =
-                                ReplaceEditViewModel.ActiveField.Scope
+                            if (it.isFocused) onIntent(ReplaceEditIntent.SetActiveField(ActiveField.Scope))
                         }
                 )
 
                 AppTextField(
                     value = state.excludeScope,
-                    onValueChange = viewModel::onExcludeScopeChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnExcludeScopeChange(it)) },
                     label = "排除范围",
                     placeholder = { AppText("指定规则不适用的范围") },
                     modifier = Modifier
                         .fillMaxWidth()
                         .onFocusChanged {
-                            if (it.isFocused) viewModel.activeField =
-                                ReplaceEditViewModel.ActiveField.Exclude
+                            if (it.isFocused) onIntent(ReplaceEditIntent.SetActiveField(ActiveField.Exclude))
                         }
                 )
 
                 AppTextField(
                     value = state.timeout,
-                    onValueChange = viewModel::onTimeoutChange,
+                    onValueChange = { onIntent(ReplaceEditIntent.OnTimeoutChange(it)) },
                     label = "超时 (ms)",
                     placeholder = { AppText("3000") },
                     modifier = Modifier.fillMaxWidth()
@@ -298,8 +317,8 @@ fun ReplaceEditScreen(
             ManageGroupDialog(
                 show = state.showGroupDialog,
                 groups = state.allGroups.filter { it != "默认" },
-                onDismiss = { viewModel.toggleGroupDialog(false) },
-                onDelete = { viewModel.deleteGroups(it) }
+                onDismiss = { onIntent(ReplaceEditIntent.ToggleGroupDialog(false)) },
+                onDelete = { onIntent(ReplaceEditIntent.DeleteGroups(it)) }
             )
         }
     }

+ 56 - 47
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt

@@ -13,29 +13,14 @@ import io.legado.app.utils.getClipText
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
-
-data class ReplaceEditUiState(
-    val id: Long = 0,
-    val name: String = "",
-    val group: String = "默认",
-    val pattern: String = "",
-    val replacement: String = "",
-    val isRegex: Boolean = false,
-    val scope: String = "",
-    val scopeTitle: Boolean = false,
-    val scopeContent: Boolean = false,
-    val excludeScope: String = "",
-    val timeout: String = "3000",
-    val allGroups: List<String> = emptyList(),
-    val showGroupDialog: Boolean = false
-)
-
 class ReplaceEditViewModel(
     private val app: Application,
     private val replaceRuleDao: ReplaceRuleDao,
@@ -45,14 +30,36 @@ class ReplaceEditViewModel(
     private val _uiState = MutableStateFlow(ReplaceEditUiState())
     val uiState = _uiState.asStateFlow()
 
-    var activeField: ActiveField = ActiveField.None
-    enum class ActiveField { Name, None, Pattern, Replacement, Scope, Exclude }
+    private val _effects = MutableSharedFlow<ReplaceEditEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     init {
         initData()
         observeGroups()
     }
 
+    fun onIntent(intent: ReplaceEditIntent) {
+        when (intent) {
+            is ReplaceEditIntent.OnNameChange -> onNameChange(intent.value)
+            is ReplaceEditIntent.OnPatternChange -> onPatternChange(intent.value)
+            is ReplaceEditIntent.OnReplacementChange -> onReplacementChange(intent.value)
+            is ReplaceEditIntent.OnScopeChange -> onScopeChange(intent.value)
+            is ReplaceEditIntent.OnExcludeScopeChange -> onExcludeScopeChange(intent.value)
+            is ReplaceEditIntent.OnGroupChange -> onGroupChange(intent.value)
+            is ReplaceEditIntent.OnRegexChange -> onRegexChange(intent.value)
+            is ReplaceEditIntent.OnScopeTitleChange -> onScopeTitleChange(intent.value)
+            is ReplaceEditIntent.OnScopeContentChange -> onScopeContentChange(intent.value)
+            is ReplaceEditIntent.OnTimeoutChange -> onTimeoutChange(intent.value)
+            is ReplaceEditIntent.SetActiveField -> setActiveField(intent.field)
+            is ReplaceEditIntent.InsertTextAtCursor -> insertTextAtCursor(intent.text)
+            is ReplaceEditIntent.ToggleGroupDialog -> toggleGroupDialog(intent.show)
+            is ReplaceEditIntent.DeleteGroups -> deleteGroups(intent.groups)
+            ReplaceEditIntent.CopyRule -> copyRule()
+            ReplaceEditIntent.PasteRule -> pasteRule()
+            ReplaceEditIntent.Save -> save()
+        }
+    }
+
     private fun initData() {
         viewModelScope.launch {
             val id = route.id
@@ -121,7 +128,7 @@ class ReplaceEditViewModel(
         return rule
     }
 
-    fun copyRule() {
+    private fun copyRule() {
         viewModelScope.launch(Dispatchers.Main) {
             val ruleToCopy = getReplaceRuleFromState()
             val json = GSON.toJson(ruleToCopy)
@@ -130,7 +137,7 @@ class ReplaceEditViewModel(
         }
     }
 
-    fun pasteRule(onSuccess: () -> Unit) {
+    private fun pasteRule() {
         viewModelScope.launch(Dispatchers.IO) {
             try {
                 val text = app.getClipText()
@@ -143,7 +150,6 @@ class ReplaceEditViewModel(
 
                 launch(Dispatchers.Main) {
                     updateStateFromRule(pastedRule)
-                    onSuccess()
                 }
             } catch (e: Exception) {
                 launch(Dispatchers.Main) {
@@ -153,39 +159,44 @@ class ReplaceEditViewModel(
         }
     }
 
-    fun onNameChange(v: String) {
+    private fun onNameChange(v: String) {
         _uiState.update { it.copy(name = v) }
-        activeField = ActiveField.Name
+        setActiveField(ActiveField.Name)
     }
 
-    fun onScopeChange(v: String) {
+    private fun onScopeChange(v: String) {
         _uiState.update { it.copy(scope = v) }
-        activeField = ActiveField.Scope
+        setActiveField(ActiveField.Scope)
     }
 
-    fun onPatternChange(v: String) {
+    private fun onPatternChange(v: String) {
         _uiState.update { it.copy(pattern = v) }
-        activeField = ActiveField.Pattern
+        setActiveField(ActiveField.Pattern)
     }
 
-    fun onReplacementChange(v: String) {
+    private fun onReplacementChange(v: String) {
         _uiState.update { it.copy(replacement = v) }
-        activeField = ActiveField.Replacement
+        setActiveField(ActiveField.Replacement)
     }
 
-    fun onExcludeScopeChange(v: String) {
+    private fun onExcludeScopeChange(v: String) {
         _uiState.update { it.copy(excludeScope = v) }
-        activeField = ActiveField.Exclude
-    }
-    fun onGroupChange(v: String) = _uiState.update { it.copy(group = v) }
-    fun onRegexChange(v: Boolean) = _uiState.update { it.copy(isRegex = v) }
-    fun onScopeTitleChange(v: Boolean) = _uiState.update { it.copy(scopeTitle = v) }
-    fun onScopeContentChange(v: Boolean) = _uiState.update { it.copy(scopeContent = v) }
-    fun onTimeoutChange(v: String) = _uiState.update { it.copy(timeout = v) }
-    fun toggleGroupDialog(show: Boolean) = _uiState.update { it.copy(showGroupDialog = show) }
-    fun insertTextAtCursor(text: String) {
-        val state = _uiState.value
-        when (activeField) {
+        setActiveField(ActiveField.Exclude)
+    }
+
+    private fun onGroupChange(v: String) = _uiState.update { it.copy(group = v) }
+    private fun onRegexChange(v: Boolean) = _uiState.update { it.copy(isRegex = v) }
+    private fun onScopeTitleChange(v: Boolean) = _uiState.update { it.copy(scopeTitle = v) }
+    private fun onScopeContentChange(v: Boolean) = _uiState.update { it.copy(scopeContent = v) }
+    private fun onTimeoutChange(v: String) = _uiState.update { it.copy(timeout = v) }
+    private fun toggleGroupDialog(show: Boolean) = _uiState.update { it.copy(showGroupDialog = show) }
+
+    private fun setActiveField(field: ActiveField) {
+        _uiState.update { it.copy(activeField = field) }
+    }
+
+    private fun insertTextAtCursor(text: String) {
+        when (_uiState.value.activeField) {
             ActiveField.Name -> _uiState.update { it.copy(name = it.name + text) }
             ActiveField.Pattern -> _uiState.update { it.copy(pattern = it.pattern + text) }
             ActiveField.Replacement -> _uiState.update { it.copy(replacement = it.replacement + text) }
@@ -195,7 +206,7 @@ class ReplaceEditViewModel(
         }
     }
 
-    fun save(onSuccess: () -> Unit) {
+    private fun save() {
         viewModelScope.launch(Dispatchers.IO) {
             val state = _uiState.value
 
@@ -220,14 +231,12 @@ class ReplaceEditViewModel(
 
             replaceRuleDao.insert(rule)
 
-            launch(Dispatchers.Main) {
-                onSuccess()
-            }
+            _effects.tryEmit(ReplaceEditEffect.NavigateBack)
         }
     }
 
 
-    fun deleteGroups(groups: List<String>) {
+    private fun deleteGroups(groups: List<String>) {
         viewModelScope.launch {
             replaceRuleDao.clearGroups(groups)
             toggleGroupDialog(false)