Просмотр исходного кода

[新增] 为日后的迁移实现了一些与替换规则、书源管理有关的导入导出等可复用组件

HapeLee 10 месяцев назад
Родитель
Сommit
60fbaef267

+ 27 - 0
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
+        )
+    }
+
+}

+ 4 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -1,9 +1,11 @@
 package io.legado.app.di
 package io.legado.app.di
 
 
 import io.legado.app.data.AppDatabase
 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.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.ReadRecordRepository
 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.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
@@ -26,6 +28,8 @@ val appModule = module {
     single { ReadRecordRepository(get()) }
     single { ReadRecordRepository(get()) }
     viewModel { ReadRecordViewModel(get(), get(), get()) }
     viewModel { ReadRecordViewModel(get(), get(), get()) }
 
 
+    single<UploadRepository> { DirectLinkUploadRepository() }
+
     // Explore
     // Explore
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
     viewModel { ExploreShowViewModel(get()) }
     viewModel { ExploreShowViewModel(get()) }

+ 148 - 0
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<String>) -> Unit,
+    onUpload: (() -> Unit)? = null,
+    allowExtensions: Array<String>? = 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<String>?): Array<String> {
+    val types = hashSetOf<String>()
+    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()
+}

+ 329 - 0
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<String> = 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 <T> BatchImportDialog(
+    title: String,
+    importState: BaseImportUiState.Success<T>,
+    onDismissRequest: () -> Unit,
+    onConfirm: (List<T>) -> 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("导入")
+            }
+        }
+    }
+}

+ 32 - 0
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<T>(
+    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<out T> {
+    data object Idle : BaseImportUiState<Nothing>
+    data object Loading : BaseImportUiState<Nothing>
+    data class Error(val msg: String) : BaseImportUiState<Nothing>
+    data class Success<T>(
+        val source: String,
+        val items: List<ImportItemWrapper<T>>,
+        // 导入配置项
+        val keepOriginalName: Boolean = false,
+        val customGroup: String? = null,
+        val isAddGroup: Boolean = false
+    ) : BaseImportUiState<T>
+}

+ 0 - 0
app/src/main/res/values-zh/arrays.xml → app/src/main/res/values-zh-rCN/arrays.xml


+ 2 - 2
app/src/main/res/values-zh/strings.xml → app/src/main/res/values-zh-rCN/strings.xml

@@ -199,7 +199,7 @@
     <string name="replace_rule">替换规则</string>
     <string name="replace_rule">替换规则</string>
     <string name="replace_to">替换为</string>
     <string name="replace_to">替换为</string>
     <string name="img_cover">封面</string>
     <string name="img_cover">封面</string>
-   <string name="book">本书</string>
+    <string name="book">本书</string>
     <string name="volume_key_page">音量键翻页</string>
     <string name="volume_key_page">音量键翻页</string>
     <string name="mouse_wheel_page">鼠标滚轮翻页</string>
     <string name="mouse_wheel_page">鼠标滚轮翻页</string>
     <string name="click_turn_page">点击翻页</string>
     <string name="click_turn_page">点击翻页</string>
@@ -1364,4 +1364,4 @@
     <string name="edit_remark">编辑备注</string>
     <string name="edit_remark">编辑备注</string>
     <string name="book_remark">书籍备注</string>
     <string name="book_remark">书籍备注</string>
     <string name="auto_check_new_backup_t">自动检查新备份</string>
     <string name="auto_check_new_backup_t">自动检查新备份</string>
-</resources>
+</resources>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -1367,4 +1367,6 @@
     <string name="enable_slider_vibrator">滑动进度条时震动</string>
     <string name="enable_slider_vibrator">滑动进度条时震动</string>
     <string name="edit_remark">编辑备注</string>
     <string name="edit_remark">编辑备注</string>
     <string name="book_remark">书籍备注</string>
     <string name="book_remark">书籍备注</string>
+    <string name="save_to_local">保存至本地</string>
+    <string name="select_operation">选择操作</string>
 </resources>
 </resources>