From 60fbaef267493294e2b2e233667428ddd462e5de Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Sun, 21 Dec 2025 04:19:17 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E4=B8=BA=E6=97=A5?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E8=BF=81=E7=A7=BB=E5=AE=9E=E7=8E=B0=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E4=B8=8E=E6=9B=BF=E6=8D=A2=E8=A7=84=E5=88=99?= =?UTF-8?q?=E3=80=81=E4=B9=A6=E6=BA=90=E7=AE=A1=E7=90=86=E6=9C=89=E5=85=B3?= =?UTF-8?q?=E7=9A=84=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA=E7=AD=89=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/data/repository/UploadRepository.kt | 27 ++ .../main/java/io/legado/app/di/appModule.kt | 4 + .../exportComponents/FilePickerSheet.kt | 148 ++++++++ .../importComponents/ImportComponents.kt | 329 ++++++++++++++++++ .../importComponents/ImportState.kt | 32 ++ .../{values-zh => values-zh-rCN}/arrays.xml | 0 .../{values-zh => values-zh-rCN}/strings.xml | 4 +- app/src/main/res/values/strings.xml | 2 + 8 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/repository/UploadRepository.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt rename app/src/main/res/{values-zh => values-zh-rCN}/arrays.xml (100%) rename app/src/main/res/{values-zh => values-zh-rCN}/strings.xml (99%) diff --git a/app/src/main/java/io/legado/app/data/repository/UploadRepository.kt b/app/src/main/java/io/legado/app/data/repository/UploadRepository.kt new file mode 100644 index 000000000..99391b694 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/UploadRepository.kt @@ -0,0 +1,27 @@ +package io.legado.app.data.repository + +import io.legado.app.help.DirectLinkUpload + +interface UploadRepository { + suspend fun upload( + fileName: String, + file: Any, + contentType: String + ): String +} + +class DirectLinkUploadRepository : UploadRepository { + + override suspend fun upload( + fileName: String, + file: Any, + contentType: String + ): String { + return DirectLinkUpload.upLoad( + fileName = fileName, + file = file, + contentType = contentType + ) + } + +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 5996abb33..c51ec4243 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -1,9 +1,11 @@ package io.legado.app.di import io.legado.app.data.AppDatabase +import io.legado.app.data.repository.DirectLinkUploadRepository import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel @@ -26,6 +28,8 @@ val appModule = module { single { ReadRecordRepository(get()) } viewModel { ReadRecordViewModel(get(), get(), get()) } + single { DirectLinkUploadRepository() } + // Explore single { ExploreRepositoryImpl(get()) } viewModel { ExploreShowViewModel(get()) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt new file mode 100644 index 000000000..300739eac --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt @@ -0,0 +1,148 @@ +package io.legado.app.ui.widget.components.exportComponents + +import android.webkit.MimeTypeMap +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R + +enum class FilePickerSheetMode { + DIR, FILE, EXPORT +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilePickerSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + mode: FilePickerSheetMode, + onSelectSysDir: () -> Unit, + onSelectSysFile: (Array) -> Unit, + onUpload: (() -> Unit)? = null, + allowExtensions: Array? = null, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + text = stringResource(R.string.select_operation), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + when (mode) { + FilePickerSheetMode.DIR -> { + FilePickerOptionCard( + icon = Icons.Default.FolderOpen, + text = stringResource(R.string.sys_folder_picker), + onClick = onSelectSysDir + ) + } + FilePickerSheetMode.FILE -> { + FilePickerOptionCard( + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + text = stringResource(R.string.sys_file_picker), + onClick = { onSelectSysFile(typesOfExtensions(allowExtensions)) } + ) + } + FilePickerSheetMode.EXPORT -> { + FilePickerOptionCard( + icon = Icons.Default.SaveAlt, + text = stringResource(R.string.save_to_local), + onClick = onSelectSysDir + ) + + if (onUpload != null) { + FilePickerOptionCard( + icon = Icons.Default.CloudUpload, + text = stringResource(R.string.upload_url), + onClick = onUpload + ) + } + } + } + } + } + } +} + +@Composable +private fun RowScope.FilePickerOptionCard( + icon: ImageVector, + text: String, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + modifier = Modifier + .weight(1f) + .height(100.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + maxLines = 1 + ) + } + } +} + +private fun typesOfExtensions(allowExtensions: Array?): Array { + val types = hashSetOf() + if (allowExtensions.isNullOrEmpty()) { + types.add("*/*") + } else { + allowExtensions.forEach { + when (it) { + "*" -> types.add("*/*") + "txt", "xml" -> types.add("text/*") + else -> { + val mime = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(it) + ?: "application/octet-stream" + types.add(mime) + } + } + } + } + return types.toTypedArray() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt new file mode 100644 index 000000000..707d2df02 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt @@ -0,0 +1,329 @@ +package io.legado.app.ui.widget.components.importComponents + +import android.annotation.SuppressLint +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.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.widget.components.AnimatedText +import kotlinx.coroutines.launch + +@Composable +fun SourceInputDialog( + title: String = "网络导入", + hint: String = "请输入 URL 或 JSON", + initialValue: String = "", + historyValues: List = emptyList(), // 历史记录 + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit +) { + var text by remember { mutableStateOf(initialValue) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(hint) }, + modifier = Modifier.fillMaxWidth(), + maxLines = 5 + ) + + if (historyValues.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text("历史记录:", style = MaterialTheme.typography.labelSmall) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(historyValues) { history -> + AssistChip( + onClick = { text = history }, + label = { Text(history, maxLines = 1) } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { if (text.isNotBlank()) onConfirm(text) } + ) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@SuppressLint("ConfigurationScreenWidthHeight") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BatchImportDialog( + title: String, + importState: BaseImportUiState.Success, + onDismissRequest: () -> Unit, + onConfirm: (List) -> Unit, // 返回选中的原始数据列表 + onToggleItem: (index: Int) -> Unit, + onToggleAll: (isSelected: Boolean) -> Unit, + onItemInfoClick: (index: Int) -> Unit = {}, + // 插槽:允许调用方自定义顶部菜单 + topBarActions: @Composable RowScope.() -> Unit = {}, + // 插槽:自定义每一行的显示内容 + itemContent: @Composable (data: T, status: ImportStatus) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val selectedCount = importState.items.count { it.isSelected } + val totalCount = importState.items.size + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Scaffold( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f), + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { + AnimatedText( + if (selectedCount > 0) + stringResource( + R.string.select_count, + selectedCount, + totalCount + ) + else + title + ) + }, + actions = topBarActions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + ImportBottomBar( + selectedCount = selectedCount, + totalCount = totalCount, + onToggleSelectAll = { isAll -> onToggleAll(isAll) }, + onConfirm = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + val selectedData = + importState.items.filter { it.isSelected }.map { it.data } + onConfirm(selectedData) + } + } + }, + onCancel = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onDismissRequest() + } + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(importState.items, key = { _, item -> item.data.hashCode() }) { index, itemWrapper -> + ImportItemRow( + isSelected = itemWrapper.isSelected, + status = itemWrapper.status, + onClick = { onToggleItem(index) }, + onInfoClick = { + scope.launch { + snackbarHostState.showSnackbar("其实还没写桀桀桀") + } + onItemInfoClick(index) + }, + content = { itemContent(itemWrapper.data, itemWrapper.status) } + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ImportItemRow( + isSelected: Boolean, + status: ImportStatus, + onClick: () -> Unit, + onInfoClick: () -> Unit, + content: @Composable () -> Unit +) { + val containerColor by animateColorAsState( + targetValue = if (isSelected) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.surface, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ), + label = "CardColor" + ) + + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = containerColor + ) + ) { + ListItem( + modifier = Modifier.animateContentSize(), + headlineContent = { content() }, + leadingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = null + ) + }, + supportingContent = { + Text( + text = when (status) { + ImportStatus.New -> "新增" + ImportStatus.Update -> "更新" + ImportStatus.Existing -> "已有" + ImportStatus.Error -> "错误" + }, + style = MaterialTheme.typography.labelMedium, + color = when (status) { + ImportStatus.New -> MaterialTheme.colorScheme.primary + ImportStatus.Update -> MaterialTheme.colorScheme.secondary + ImportStatus.Error -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.outline + } + ) + }, + trailingContent = { + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "详情" + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ImportBottomBar( + selectedCount: Int, + totalCount: Int, + onToggleSelectAll: (Boolean) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val allSelected = selectedCount == totalCount + OutlinedToggleButton( + checked = allSelected, + onCheckedChange = { checked -> + onToggleSelectAll(checked) + }, + ) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = if (allSelected) "全不选" else "全选" + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onCancel) { Text("取消") } + Button( + enabled = selectedCount > 0, + onClick = onConfirm + ) { + Text("导入") + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt new file mode 100644 index 000000000..f8f6673a9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.widget.components.importComponents + +// 单个条目的状态包装 +data class ImportItemWrapper( + val data: T,// 具体的数据对象 (ReplaceRule, BookSource, etc.) + val oldData: T? = null, + val isSelected: Boolean = true, + val status: ImportStatus = ImportStatus.New // 用于UI显示颜色 +) + +// 导入状态枚举 +enum class ImportStatus { + New, // 新增 绿 + Update, // 更新 黄 + Existing, // 已有 灰 + Error // 错误 红 +} + +// 整个导入流程的 UI State +sealed interface BaseImportUiState { + data object Idle : BaseImportUiState + data object Loading : BaseImportUiState + data class Error(val msg: String) : BaseImportUiState + data class Success( + val source: String, + val items: List>, + // 导入配置项 + val keepOriginalName: Boolean = false, + val customGroup: String? = null, + val isAddGroup: Boolean = false + ) : BaseImportUiState +} \ No newline at end of file diff --git a/app/src/main/res/values-zh/arrays.xml b/app/src/main/res/values-zh-rCN/arrays.xml similarity index 100% rename from app/src/main/res/values-zh/arrays.xml rename to app/src/main/res/values-zh-rCN/arrays.xml diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml similarity index 99% rename from app/src/main/res/values-zh/strings.xml rename to app/src/main/res/values-zh-rCN/strings.xml index 78453d4d9..c9baa88ea 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -199,7 +199,7 @@ 替换规则 替换为 封面 - 本书 + 本书 音量键翻页 鼠标滚轮翻页 点击翻页 @@ -1364,4 +1364,4 @@ 编辑备注 书籍备注 自动检查新备份 - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 912eeca50..5cf629d03 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1367,4 +1367,6 @@ 滑动进度条时震动 编辑备注 书籍备注 + 保存至本地 + 选择操作