From cb7717ff1ad8276a74ad933529f56b69d01441ba Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Sun, 21 Dec 2025 04:14:52 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E4=B8=BA=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E5=87=80=E5=8C=96=E7=95=8C=E9=9D=A2=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=86=E5=8E=9FView=E7=95=8C=E9=9D=A2=E7=9A=84=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=AF=BC=E5=85=A5=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/replace/ReplaceRuleScreen.kt | 177 ++++++++++++++++-- .../app/ui/replace/ReplaceRuleViewModel.kt | 155 +++++++++++++++ 2 files changed, 317 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 6905ed9fd..2ed3430ee 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -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)?.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( diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index 5d374d425..f2e892e5b 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -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>(emptyList()) private val _selectedRuleIds = MutableStateFlow>(emptySet()) val selectedRuleIds: StateFlow> = _selectedRuleIds + private val _importState = MutableStateFlow>(BaseImportUiState.Idle) + val importState: StateFlow> = _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 { + 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() + 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 + } + } + } + } \ No newline at end of file