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

This commit is contained in:
HapeLee committed 2025-12-21 04:14:52 +08:00
1 parent 40159c23f7
commit cb7717ff1a
2 files changed
+317 -15

No files matched your search

@@ -1,14 +1,20 @@
package io.legado.app.ui.replace package io.legado.app.ui.replace
import android.content.ClipData
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -27,6 +33,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput 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.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.gson.Gson import com.google.gson.Gson
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.ReplaceRule 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.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AnimatedText import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.FastScrollLazyColumn import io.legado.app.ui.widget.components.FastScrollLazyColumn
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SelectionBottomBar 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.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable @Composable
fun ReplaceRuleScreen( fun ReplaceRuleScreen(
viewModel: ReplaceRuleViewModel = koinViewModel(), viewModel: ReplaceRuleViewModel = koinViewModel(),
onBackClick: () -> Unit onBackClick: () -> Unit
) { ) {
val uploadRepository: UploadRepository = koinInject()
//TODO: 期望换为Navigation //TODO: 期望换为Navigation
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val rules = uiState.rules val rules = uiState.rules
@@ -85,13 +110,21 @@ fun ReplaceRuleScreen(
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = listOf(stringResource(R.string.all)) + groups 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( val importDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(), contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> onResult = { uri ->
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()
// show import dialog viewModel.importSource(text)
} }
} }
} }
@@ -100,7 +133,7 @@ fun ReplaceRuleScreen(
val exportDoc = rememberLauncherForActivityResult( val exportDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"), contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri -> onResult = { uri ->
uri?.let { uri?.let { it ->
scope.launch { scope.launch {
val rulesToExport = rules val rulesToExport = rules
.filter { selectedRuleIds.contains(it.id) } .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) { if (showGroupManageSheet) {
GroupManageBottomSheet( GroupManageBottomSheet(
groups = groups, groups = groups,
@@ -181,20 +312,20 @@ fun ReplaceRuleScreen(
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Column { Column {
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) {
when {
isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}"
else -> "替换规则"
}
}
MediumTopAppBar( MediumTopAppBar(
title = { title = {
AnimatedText( AnimatedText(
text = if (inSelectionMode) { text = titleText
stringResource(
R.string.select_count,
selectedRuleIds.size,
rules.size
)
} else {
stringResource(R.string.replace_rule)
}
) )
}, },
navigationIcon = { navigationIcon = {
@@ -227,7 +358,10 @@ fun ReplaceRuleScreen(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("在线导入") }, text = { Text("在线导入") },
onClick = { /*TODO*/ showMenu = false } onClick = {
showMenu = false
showUrlInput = true // 触发输入框
}
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("本地导入") }, text = { Text("本地导入") },
@@ -431,7 +565,7 @@ fun ReplaceRuleScreen(
), ),
ActionItem( ActionItem(
text = stringResource(R.string.export), text = stringResource(R.string.export),
onClick = { exportDoc.launch("exportReplaceRule.json") } onClick = { showFilePickerSheet = true }
) )
) )
) )
@@ -618,6 +752,19 @@ fun ReplaceRuleItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showRuleMenu by remember { mutableStateOf(false) } 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( Card(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -628,7 +775,7 @@ fun ReplaceRuleItem(
), ),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow containerColor = containerColor
) )
) { ) {
ListItem( ListItem(
@@ -2,26 +2,48 @@ package io.legado.app.ui.replace
import android.app.Application import android.app.Application
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.BaseViewModel 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.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.ReplaceRuleRepository 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.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.putPrefString
import io.legado.app.utils.readText
import io.legado.app.utils.splitNotBlank
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx import splitties.init.appCtx
@Immutable @Immutable
@@ -53,6 +75,8 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList()) private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList())
private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet()) private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds 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) { fun toggleSelection(id: Long) {
_selectedRuleIds.update { _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
}
}
}
} }