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

This commit is contained in:
HapeLee committed 2026-06-27 02:27:43 +08:00
1 parent 5195d51b08
commit da6c02e2ca
7 files changed
+378 -185

No files matched your search

@@ -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) {
@@ -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
@@ -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,16 +63,47 @@ 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 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(
viewModel: ReplaceRuleViewModel = koinViewModel(),
state: ReplaceRuleUiState,
importState: BaseImportUiState<ReplaceRule>,
events: Flow<BaseRuleEvent>,
groups: List<String>,
onIntent: (ReplaceRuleIntent) -> Unit,
onBackClick: () -> Unit,
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
) {
@@ -83,10 +111,8 @@ fun ReplaceRuleScreen(
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(