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

This commit is contained in:
HapeLee
2025-12-21 04:19:17 +08:00
parent cb7717ff1a
commit 60fbaef267
8 changed files with 544 additions and 2 deletions
@@ -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
)
}
}
@@ -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<UploadRepository> { DirectLinkUploadRepository() }
// Explore
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
viewModel { ExploreShowViewModel(get()) }
@@ -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()
}
@@ -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("导入")
}
}
}
}
@@ -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>
}
@@ -199,7 +199,7 @@
<string name="replace_rule">替换规则</string>
<string name="replace_to">替换为</string>
<string name="img_cover">封面</string>
<string name="book">本书</string>
<string name="book">本书</string>
<string name="volume_key_page">音量键翻页</string>
<string name="mouse_wheel_page">鼠标滚轮翻页</string>
<string name="click_turn_page">点击翻页</string>
@@ -1364,4 +1364,4 @@
<string name="edit_remark">编辑备注</string>
<string name="book_remark">书籍备注</string>
<string name="auto_check_new_backup_t">自动检查新备份</string>
</resources>
</resources>
+2
View File
@@ -1367,4 +1367,6 @@
<string name="enable_slider_vibrator">滑动进度条时震动</string>
<string name="edit_remark">编辑备注</string>
<string name="book_remark">书籍备注</string>
<string name="save_to_local">保存至本地</string>
<string name="select_operation">选择操作</string>
</resources>