Эх сурвалжийг харах

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

HapeLee 10 сар өмнө
parent
commit
cb7717ff1a

+ 162 - 15
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<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(

+ 155 - 0
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<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
+            }
+        }
+    }
+
 }