[优化] 为替换净化界面实现了原View界面的导出导入等功能

This commit is contained in:
HapeLee
2025-12-21 04:14:52 +08:00
parent 40159c23f7
commit cb7717ff1a
2 changed files with 317 additions and 15 deletions
@@ -1,14 +1,20 @@
package io.legado.app.ui.replace
import android.content.ClipData
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -27,6 +33,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -34,36 +41,54 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.gson.Gson
import io.legado.app.R
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.FastScrollLazyColumn
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SelectionBottomBar
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
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
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun ReplaceRuleScreen(
viewModel: ReplaceRuleViewModel = koinViewModel(),
onBackClick: () -> Unit
) {
val uploadRepository: UploadRepository = koinInject()
//TODO: 期望换为Navigation
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val rules = uiState.rules
@@ -85,13 +110,21 @@ fun ReplaceRuleScreen(
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = listOf(stringResource(R.string.all)) + groups
val importState by viewModel.importState.collectAsStateWithLifecycle()
var showUrlInput by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
var isUploading by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboard.current
val importDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { stream ->
val text = stream.reader().readText()
// show import dialog
viewModel.importSource(text)
}
}
}
@@ -100,7 +133,7 @@ fun ReplaceRuleScreen(
val exportDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri ->
uri?.let {
uri?.let { it ->
scope.launch {
val rulesToExport = rules
.filter { selectedRuleIds.contains(it.id) }
@@ -115,6 +148,104 @@ fun ReplaceRuleScreen(
}
)
if (showUrlInput) {
SourceInputDialog(
title = stringResource(R.string.import_on_line),
onDismissRequest = { showUrlInput = false },
onConfirm = {
showUrlInput = false
viewModel.importSource(it)
}
)
}
if (showFilePickerSheet) {
FilePickerSheet(
sheetState = sheetState,
onDismissRequest = { showFilePickerSheet = false },
mode = filePickerMode,
onSelectSysDir = {
showFilePickerSheet = false
exportDoc.launch("exportReplaceRule.json")
},
onSelectSysFile = {},
onUpload = {
showFilePickerSheet = false
scope.launch {
val rulesToExport = rules
.filter { selectedRuleIds.contains(it.id) }
.map { it.rule }
val json = Gson().toJson(rulesToExport)
// [新增] 设置加载状态
isUploading = true
try {
// 使用 runCatching 处理上传
runCatching {
uploadRepository.upload(
fileName = "exportReplaceRule.json",
file = json,
contentType = "application/json"
)
}.onSuccess { url ->
isUploading = false
val result = snackbarHostState.showSnackbar(
message = "上传成功: $url",
actionLabel = "复制链接",
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed) {
clipboardManager.setClipEntry(
ClipEntry(ClipData.newPlainText("export url", url))
)
}
}.onFailure { e ->
isUploading = false
snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
}
} finally {
isUploading = false
}
}
},
allowExtensions = arrayOf("json")
)
}
(importState as? BaseImportUiState.Success<ReplaceRule>)?.let { state ->
BatchImportDialog(
title = stringResource(R.string.import_replace_rule),
importState = state,
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onConfirm = { viewModel.saveImportedRules() },
topBarActions = {},
itemContent = { rule, _ ->
Column {
Text(rule.name, style = MaterialTheme.typography.titleMedium)
if (!rule.group.isNullOrBlank()) {
Text(rule.group!!, style = MaterialTheme.typography.bodySmall)
}
}
}
)
}
if (importState is BaseImportUiState.Loading) {
Dialog(onDismissRequest = { viewModel.cancelImport() }) { LoadingIndicator() }
}
LaunchedEffect(importState) {
(importState as? BaseImportUiState.Error)?.let {
scope.launch {
snackbarHostState.showSnackbar(it.msg)
}
viewModel.cancelImport()
}
}
if (showGroupManageSheet) {
GroupManageBottomSheet(
groups = groups,
@@ -181,20 +312,20 @@ fun ReplaceRuleScreen(
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Column {
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) {
when {
isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}"
else -> "替换规则"
}
}
MediumTopAppBar(
title = {
AnimatedText(
text = if (inSelectionMode) {
stringResource(
R.string.select_count,
selectedRuleIds.size,
rules.size
)
} else {
stringResource(R.string.replace_rule)
}
text = titleText
)
},
navigationIcon = {
@@ -227,7 +358,10 @@ fun ReplaceRuleScreen(
) {
DropdownMenuItem(
text = { Text("在线导入") },
onClick = { /*TODO*/ showMenu = false }
onClick = {
showMenu = false
showUrlInput = true // 触发输入框
}
)
DropdownMenuItem(
text = { Text("本地导入") },
@@ -431,7 +565,7 @@ fun ReplaceRuleScreen(
),
ActionItem(
text = stringResource(R.string.export),
onClick = { exportDoc.launch("exportReplaceRule.json") }
onClick = { showFilePickerSheet = true }
)
)
)
@@ -618,6 +752,19 @@ fun ReplaceRuleItem(
modifier: Modifier = Modifier
) {
var showRuleMenu by remember { mutableStateOf(false) }
val containerColor by animateColorAsState(
targetValue = if (isSelected)
MaterialTheme.colorScheme.secondaryContainer
else
MaterialTheme.colorScheme.surfaceContainerLow,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
),
label = "CardColor"
)
Card(
modifier = modifier
.fillMaxWidth()
@@ -628,7 +775,7 @@ fun ReplaceRuleItem(
),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow
containerColor = containerColor
)
) {
ListItem(
@@ -2,26 +2,48 @@ package io.legado.app.ui.replace
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppPattern
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.ReplaceRuleRepository
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.ReplaceAnalyzer
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
import io.legado.app.ui.widget.components.importComponents.ImportStatus
import io.legado.app.utils.getPrefString
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isUri
import io.legado.app.utils.putPrefString
import io.legado.app.utils.readText
import io.legado.app.utils.splitNotBlank
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
@Immutable
@@ -53,6 +75,8 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList())
private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds
private val _importState = MutableStateFlow<BaseImportUiState<ReplaceRule>>(BaseImportUiState.Idle)
val importState: StateFlow<BaseImportUiState<ReplaceRule>> = _importState.asStateFlow()
fun toggleSelection(id: Long) {
_selectedRuleIds.update {
@@ -265,4 +289,135 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
}
}
fun importSource(text: String) {
_importState.value = BaseImportUiState.Loading
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val jsonText = resolveSource(text.trim())
val rules = parseRules(jsonText)
val wrappers = rules.map { newRule ->
val oldRule = appDb.replaceRuleDao.findById(newRule.id)
val status = when {
oldRule == null -> ImportStatus.New
hasChanged(newRule, oldRule) -> ImportStatus.Update
else -> ImportStatus.Existing
}
ImportItemWrapper(
data = newRule,
oldData = oldRule,
status = status,
isSelected = status != ImportStatus.Existing
)
}
_importState.value = BaseImportUiState.Success(
source = text,
items = wrappers
)
}.onFailure {
it.printStackTrace()
_importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
}
}
}
private suspend fun resolveSource(text: String): String {
return when {
text.isAbsUrl() -> {
okHttpClient.newCallResponseBody {
if (text.endsWith("#requestWithoutUA")) {
url(text.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(text)
}
}.decompressed().text("utf-8")
}
text.isUri() -> text.toUri().readText(appCtx)
else -> text
}
}
private fun parseRules(text: String): List<ReplaceRule> {
return when {
text.isJsonArray() -> ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow()
text.isJsonObject() -> listOf(ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow())
else -> throw Exception("格式不正确")
}
}
private fun hasChanged(newRule: ReplaceRule, oldRule: ReplaceRule): Boolean {
return newRule.pattern != oldRule.pattern
|| newRule.replacement != oldRule.replacement
|| newRule.isRegex != oldRule.isRegex
|| newRule.scope != oldRule.scope
}
fun cancelImport() {
_importState.value = BaseImportUiState.Idle
}
fun toggleImportSelection(index: Int) {
val currentState = _importState.value as? BaseImportUiState.Success ?: return
val newItems = currentState.items.toMutableList()
val item = newItems[index]
newItems[index] = item.copy(isSelected = !item.isSelected)
_importState.value = currentState.copy(items = newItems)
}
fun toggleImportAll(isSelected: Boolean) {
val currentState = _importState.value as? BaseImportUiState.Success ?: return
val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
_importState.value = currentState.copy(items = newItems)
}
// 更新分组配置
fun updateImportConfig(keepName: Boolean? = null, group: String? = null, isAdd: Boolean? = null) {
val currentState = _importState.value as? BaseImportUiState.Success ?: return
_importState.value = currentState.copy(
keepOriginalName = keepName ?: currentState.keepOriginalName,
customGroup = group ?: currentState.customGroup,
isAddGroup = isAdd ?: currentState.isAddGroup
)
}
fun saveImportedRules() {
val state = _importState.value as? BaseImportUiState.Success ?: return
viewModelScope.launch(Dispatchers.IO) {
val rulesToSave = state.items
.filter { it.isSelected }
.map { wrapper ->
val rule = wrapper.data
val oldRule = wrapper.oldData
if (state.keepOriginalName && oldRule != null) {
rule.name = oldRule.name
}
val targetGroup = state.customGroup?.trim()
if (!targetGroup.isNullOrEmpty()) {
if (state.isAddGroup) {
val groups = linkedSetOf<String>()
rule.group?.splitNotBlank(AppPattern.splitGroupRegex)?.let { groups.addAll(it) }
groups.add(targetGroup)
rule.group = groups.joinToString(",")
} else {
rule.group = targetGroup
}
}
rule
}
appDb.replaceRuleDao.insert(*rulesToSave.toTypedArray())
withContext(Dispatchers.Main) {
_importState.value = BaseImportUiState.Idle
}
}
}
}