[优化] 优化本地书籍界面
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package io.legado.app.ui.book.import.local
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateContentSize
|
||||
@@ -21,12 +22,14 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -58,7 +61,7 @@ import io.legado.app.constant.AppConst
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.ui.config.importBookConfig.ImportBookConfig
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
import io.legado.app.lib.dialogs.selector
|
||||
import io.legado.app.lib.permission.Permissions
|
||||
@@ -69,11 +72,13 @@ import io.legado.app.ui.widget.components.ActionItem
|
||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||
import io.legado.app.ui.widget.components.SelectionActions
|
||||
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
|
||||
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
||||
import io.legado.app.ui.widget.components.card.GlassCard
|
||||
import io.legado.app.ui.widget.components.card.TextCard
|
||||
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
|
||||
import io.legado.app.ui.widget.components.icon.AppIcon
|
||||
import io.legado.app.ui.widget.components.list.ListScaffold
|
||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
@@ -102,8 +107,10 @@ private fun ImportBookContent(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToLevel: (Int) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onSelectInvert: () -> Unit,
|
||||
onAddToBookshelf: () -> Unit,
|
||||
onItemAddToBookshelf: (ImportBook) -> Unit,
|
||||
onDeleteSelection: () -> Unit,
|
||||
onItemClick: (ImportBook) -> Unit
|
||||
) {
|
||||
@@ -182,6 +189,7 @@ private fun ImportBookContent(
|
||||
)
|
||||
},
|
||||
selectionActions = SelectionActions(
|
||||
onClearSelection = onClearSelection,
|
||||
onSelectAll = onSelectAll,
|
||||
onSelectInvert = onSelectInvert,
|
||||
primaryAction = ActionItem(
|
||||
@@ -227,7 +235,8 @@ private fun ImportBookContent(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
isSelected = item.selectionId in state.selectedIds,
|
||||
onClick = { onItemClick(item) }
|
||||
onClick = { onItemClick(item) },
|
||||
onAddToBookshelf = { onItemAddToBookshelf(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,13 +255,25 @@ fun ImportBookScreen(
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showFolderPicker by remember { mutableStateOf(false) }
|
||||
var showImportFileNameDialog by remember { mutableStateOf(false) }
|
||||
var fileNameJs by remember { mutableStateOf(AppConfig.bookImportFileName.orEmpty()) }
|
||||
var pendingSingleAddBook by remember { mutableStateOf<ImportBook?>(null) }
|
||||
var fileNameJs by remember { mutableStateOf(ImportBookConfig.bookImportFileName.orEmpty()) }
|
||||
var pickerTarget by remember { mutableStateOf(ImportFolderPickTarget.IMPORT_FOLDER) }
|
||||
val selectDocTree = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget))
|
||||
}
|
||||
val handleBack = {
|
||||
when {
|
||||
uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
|
||||
uiState.canGoBack -> viewModel.dispatch(ImportBookIntent.NavigateBack)
|
||||
else -> onBackClick()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) {
|
||||
handleBack()
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.dispatch(ImportBookIntent.Initialize)
|
||||
@@ -320,30 +341,48 @@ fun ImportBookScreen(
|
||||
},
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
onConfirm = {
|
||||
AppConfig.bookImportFileName = fileNameJs
|
||||
ImportBookConfig.bookImportFileName = fileNameJs
|
||||
showImportFileNameDialog = false
|
||||
},
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
onDismiss = { showImportFileNameDialog = false }
|
||||
)
|
||||
|
||||
pendingSingleAddBook?.let { book ->
|
||||
AppAlertDialog(
|
||||
show = true,
|
||||
onDismissRequest = { pendingSingleAddBook = null },
|
||||
title = stringResource(R.string.add_to_bookshelf),
|
||||
text = stringResource(R.string.check_add_bookshelf, book.name),
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
onConfirm = {
|
||||
viewModel.dispatch(ImportBookIntent.AddSingleToBookshelf(book))
|
||||
pendingSingleAddBook = null
|
||||
},
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
onDismiss = { pendingSingleAddBook = null }
|
||||
)
|
||||
}
|
||||
|
||||
ImportBookContent(
|
||||
state = uiState,
|
||||
onBackClick = onBackClick,
|
||||
onBackClick = handleBack,
|
||||
onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
|
||||
onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
|
||||
onSelectFolder = { showFolderPicker = true },
|
||||
onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
|
||||
onImportFileName = {
|
||||
fileNameJs = AppConfig.bookImportFileName.orEmpty()
|
||||
fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
|
||||
showImportFileNameDialog = true
|
||||
},
|
||||
onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) },
|
||||
onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) },
|
||||
onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) },
|
||||
onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) },
|
||||
onClearSelection = { viewModel.clearSelection() },
|
||||
onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) },
|
||||
onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) },
|
||||
onItemAddToBookshelf = { pendingSingleAddBook = it },
|
||||
onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) },
|
||||
onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) }
|
||||
)
|
||||
@@ -417,7 +456,8 @@ private fun ImportBookItem(
|
||||
modifier: Modifier,
|
||||
item: ImportBook,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
onAddToBookshelf: () -> Unit
|
||||
) {
|
||||
val containerColor = if (isSelected) {
|
||||
LegadoTheme.colorScheme.secondaryContainer
|
||||
@@ -438,7 +478,7 @@ private fun ImportBookItem(
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
AppIcon(
|
||||
imageVector = when {
|
||||
item.isDir -> Icons.Default.Folder
|
||||
item.isOnBookShelf -> Icons.Outlined.Book
|
||||
@@ -446,7 +486,7 @@ private fun ImportBookItem(
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (item.isDir) {
|
||||
tint = if (item.isOnBookShelf) {
|
||||
LegadoTheme.colorScheme.primary
|
||||
} else {
|
||||
LegadoTheme.colorScheme.onSurfaceVariant
|
||||
@@ -486,10 +526,9 @@ private fun ImportBookItem(
|
||||
|
||||
if (!item.isDir && !item.isOnBookShelf) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.Check else Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = LegadoTheme.colorScheme.primary
|
||||
SmallIconButton(
|
||||
onClick = onAddToBookshelf,
|
||||
imageVector = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package io.legado.app.ui.book.import.local
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.getTreeDocumentId
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -11,29 +13,28 @@ import io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.constant.AppPattern.archiveFileRegex
|
||||
import io.legado.app.constant.AppPattern.bookFileRegex
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.model.localBook.LocalBook
|
||||
import io.legado.app.ui.config.importBookConfig.ImportBookConfig
|
||||
import io.legado.app.ui.widget.components.list.InteractionState
|
||||
import io.legado.app.ui.widget.components.list.ListUiState
|
||||
import io.legado.app.utils.AlphanumComparator
|
||||
import io.legado.app.utils.ArchiveUtils
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.delete
|
||||
import io.legado.app.utils.getPrefInt
|
||||
import io.legado.app.utils.exists
|
||||
import io.legado.app.utils.isContentScheme
|
||||
import io.legado.app.utils.isUri
|
||||
import io.legado.app.utils.list
|
||||
import io.legado.app.utils.mapParallel
|
||||
import io.legado.app.utils.putPrefInt
|
||||
import io.legado.app.utils.takePersistablePermissionSafely
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -49,6 +50,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@@ -85,6 +87,7 @@ sealed interface ImportBookIntent {
|
||||
data object SelectAll : ImportBookIntent
|
||||
data object SelectInvert : ImportBookIntent
|
||||
data object AddToBookshelf : ImportBookIntent
|
||||
data class AddSingleToBookshelf(val item: ImportBook) : ImportBookIntent
|
||||
data object DeleteSelection : ImportBookIntent
|
||||
data class ItemClick(val item: ImportBook) : ImportBookIntent
|
||||
data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
|
||||
@@ -105,6 +108,11 @@ sealed interface ImportBookEffect {
|
||||
|
||||
class ImportBookViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private enum class SourceMode {
|
||||
CURRENT_DIR,
|
||||
SCAN_RECURSIVE
|
||||
}
|
||||
|
||||
private data class InternalState(
|
||||
val rootDoc: FileDoc? = null,
|
||||
val subDocs: List<FileDoc> = emptyList(),
|
||||
@@ -112,16 +120,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
val selectedIds: Set<String> = emptySet(),
|
||||
val searchKey: String = "",
|
||||
val sort: Int,
|
||||
val sourceMode: SourceMode = SourceMode.CURRENT_DIR,
|
||||
val interaction: InteractionState = InteractionState()
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort))
|
||||
InternalState(sort = ImportBookConfig.localBookImportSort)
|
||||
)
|
||||
private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1)
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
private var scanDocJob: Job? = null
|
||||
private var autoSyncJob: Job? = null
|
||||
|
||||
fun dispatch(intent: ImportBookIntent) {
|
||||
when (intent) {
|
||||
@@ -130,7 +140,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(
|
||||
target = ImportFolderPickTarget.IMPORT_FOLDER,
|
||||
initialUri = AppConfig.importBookPath
|
||||
initialUri = ImportBookConfig.importBookPath
|
||||
?.takeIf { it.isUri() }
|
||||
?.toUri()
|
||||
)
|
||||
@@ -147,6 +157,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
ImportBookIntent.SelectAll -> selectAllCheckable()
|
||||
ImportBookIntent.SelectInvert -> invertSelection()
|
||||
ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
|
||||
is ImportBookIntent.AddSingleToBookshelf -> addSingleToBookshelf(intent.item)
|
||||
ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
|
||||
is ImportBookIntent.ItemClick -> onItemClick(intent.item)
|
||||
is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected(
|
||||
@@ -220,34 +231,66 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
fun hasRootDoc(): Boolean = _state.value.rootDoc != null
|
||||
|
||||
private fun initialize() {
|
||||
if (AppConfig.defaultBookTreeUri.isNullOrBlank()) {
|
||||
val defaultPath = ImportBookConfig.defaultBookTreeUri?.takeIf { it.isUri() }
|
||||
val effectiveDefaultPath = defaultPath ?: firstPersistedTreeUri()?.toString()
|
||||
if (effectiveDefaultPath.isNullOrBlank()) {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (AppConfig.importBookPath.isNullOrBlank()) {
|
||||
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
|
||||
if (ImportBookConfig.defaultBookTreeUri != effectiveDefaultPath) {
|
||||
ImportBookConfig.defaultBookTreeUri = effectiveDefaultPath
|
||||
}
|
||||
initRootDoc(changedFolder = false)
|
||||
val importPath = ImportBookConfig.importBookPath
|
||||
if (importPath.isNullOrBlank() || !importPath.isUri()) {
|
||||
ImportBookConfig.importBookPath = effectiveDefaultPath
|
||||
}
|
||||
initRootDoc(changedFolder = true)
|
||||
startAutoSync()
|
||||
}
|
||||
|
||||
private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) {
|
||||
uri ?: return
|
||||
uri.takePersistablePermissionSafely(context)
|
||||
val pickedUri = persistFolderPermission(uri)
|
||||
when (target) {
|
||||
ImportFolderPickTarget.DEFAULT_BOOK -> {
|
||||
AppConfig.defaultBookTreeUri = uri.toString()
|
||||
if (AppConfig.importBookPath.isNullOrBlank()) {
|
||||
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
|
||||
ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
|
||||
if (ImportBookConfig.importBookPath.isNullOrBlank()) {
|
||||
ImportBookConfig.importBookPath = ImportBookConfig.defaultBookTreeUri
|
||||
}
|
||||
}
|
||||
|
||||
ImportFolderPickTarget.IMPORT_FOLDER -> {
|
||||
AppConfig.importBookPath = uri.toString()
|
||||
ImportBookConfig.importBookPath = pickedUri.toString()
|
||||
if (ImportBookConfig.defaultBookTreeUri.isNullOrBlank()) {
|
||||
ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
initRootDoc(changedFolder = true)
|
||||
startAutoSync()
|
||||
}
|
||||
|
||||
private fun persistFolderPermission(uri: Uri): Uri {
|
||||
val normalizedUri = normalizeFolderUri(uri)
|
||||
uri.takePersistablePermissionSafely(context)
|
||||
if (normalizedUri != uri) {
|
||||
normalizedUri.takePersistablePermissionSafely(context)
|
||||
}
|
||||
return normalizedUri
|
||||
}
|
||||
|
||||
private fun normalizeFolderUri(uri: Uri): Uri {
|
||||
if (!uri.isContentScheme()) return uri
|
||||
return runCatching {
|
||||
DocumentsContract.buildTreeDocumentUri(uri.authority, getTreeDocumentId(uri))
|
||||
}.recoverCatching {
|
||||
DocumentsContract.buildTreeDocumentUri(
|
||||
uri.authority,
|
||||
DocumentsContract.getDocumentId(uri)
|
||||
)
|
||||
}.getOrDefault(uri)
|
||||
}
|
||||
|
||||
private fun initRootDoc(changedFolder: Boolean) {
|
||||
@@ -255,48 +298,111 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
reloadCurrentDoc()
|
||||
return
|
||||
}
|
||||
val lastPath = AppConfig.importBookPath
|
||||
if (lastPath.isNullOrBlank()) {
|
||||
val candidates = linkedSetOf<String>().apply {
|
||||
ImportBookConfig.importBookPath
|
||||
?.takeIf { it.isNotBlank() && it.isUri() }
|
||||
?.let(::add)
|
||||
ImportBookConfig.defaultBookTreeUri
|
||||
?.takeIf { it.isNotBlank() && it.isUri() }
|
||||
?.let(::add)
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
firstPersistedTreeUri()?.toString()?.let { persistedPath ->
|
||||
if (trySetRootDoc(persistedPath)) {
|
||||
ImportBookConfig.defaultBookTreeUri = persistedPath
|
||||
ImportBookConfig.importBookPath = persistedPath
|
||||
return
|
||||
}
|
||||
}
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val rootUri = if (lastPath.isUri()) {
|
||||
lastPath.toUri()
|
||||
} else {
|
||||
Uri.fromFile(File(lastPath))
|
||||
}
|
||||
|
||||
when {
|
||||
rootUri.isContentScheme() -> {
|
||||
kotlin.runCatching {
|
||||
val doc = DocumentFile.fromTreeUri(context, rootUri)
|
||||
if (doc == null || doc.name.isNullOrEmpty()) {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
} else {
|
||||
setRootDoc(FileDoc.fromDocumentFile(doc))
|
||||
}
|
||||
}.onFailure {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
for (path in candidates) {
|
||||
if (trySetRootDoc(path)) {
|
||||
if (ImportBookConfig.importBookPath != path) {
|
||||
ImportBookConfig.importBookPath = path
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
kotlin.runCatching {
|
||||
setRootDoc(FileDoc.fromFile(File(rootUri.path!!)))
|
||||
}.onFailure {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
val persistedUri = path.takeIf { it.isUri() }
|
||||
?.toUri()
|
||||
?.let(::findPersistedTreeUri)
|
||||
if (persistedUri != null) {
|
||||
val persistedPath = persistedUri.toString()
|
||||
if (trySetRootDoc(persistedPath)) {
|
||||
ImportBookConfig.importBookPath = persistedPath
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstPersistedTreeUri()?.toString()?.let { persistedPath ->
|
||||
if (trySetRootDoc(persistedPath)) {
|
||||
ImportBookConfig.defaultBookTreeUri = persistedPath
|
||||
ImportBookConfig.importBookPath = persistedPath
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
|
||||
)
|
||||
}
|
||||
|
||||
private fun trySetRootDoc(path: String): Boolean {
|
||||
val rootUri = if (path.isUri()) path.toUri() else Uri.fromFile(File(path))
|
||||
return kotlin.runCatching {
|
||||
if (rootUri.isContentScheme()) {
|
||||
val doc = DocumentFile.fromTreeUri(context, rootUri)
|
||||
?: DocumentFile.fromSingleUri(context, rootUri)
|
||||
?: return false
|
||||
val rootName = doc.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: runCatching {
|
||||
getTreeDocumentId(rootUri).substringAfter(':').ifBlank { "root" }
|
||||
}.getOrElse { "root" }
|
||||
setRootDoc(
|
||||
FileDoc(
|
||||
name = rootName,
|
||||
isDir = true,
|
||||
size = doc.length(),
|
||||
lastModified = doc.lastModified(),
|
||||
uri = doc.uri
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val file = File(rootUri.path ?: return false)
|
||||
if (!file.exists() || !file.isDirectory) return false
|
||||
setRootDoc(FileDoc.fromFile(file))
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun findPersistedTreeUri(targetUri: Uri): Uri? {
|
||||
if (!targetUri.isContentScheme()) return null
|
||||
val permissions = context.contentResolver.persistedUriPermissions
|
||||
if (permissions.isEmpty()) return null
|
||||
val targetAuthority = targetUri.authority
|
||||
val targetTreeId = runCatching { getTreeDocumentId(targetUri) }.getOrNull()
|
||||
|
||||
return permissions.firstOrNull { permission ->
|
||||
permission.isReadPermission
|
||||
&& permission.uri.authority == targetAuthority
|
||||
&& runCatching { getTreeDocumentId(permission.uri) }.getOrNull() == targetTreeId
|
||||
}?.uri ?: permissions.firstOrNull { permission ->
|
||||
permission.isReadPermission && permission.uri.authority == targetAuthority
|
||||
}?.uri
|
||||
}
|
||||
|
||||
private fun firstPersistedTreeUri(): Uri? {
|
||||
return context.contentResolver.persistedUriPermissions
|
||||
.firstOrNull { it.isReadPermission && it.uri.isContentScheme() }
|
||||
?.uri
|
||||
?.let(::normalizeFolderUri)
|
||||
}
|
||||
|
||||
fun clearRoot() {
|
||||
@@ -305,7 +411,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
rootDoc = null,
|
||||
subDocs = emptyList(),
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet()
|
||||
selectedIds = emptySet(),
|
||||
sourceMode = SourceMode.CURRENT_DIR
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -315,7 +422,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
it.copy(
|
||||
rootDoc = rootDoc,
|
||||
subDocs = emptyList(),
|
||||
selectedIds = emptySet()
|
||||
selectedIds = emptySet(),
|
||||
sourceMode = SourceMode.CURRENT_DIR
|
||||
)
|
||||
}
|
||||
loadCurrentDoc()
|
||||
@@ -369,7 +477,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
|
||||
fun setSort(sort: Int) {
|
||||
_state.update { it.copy(sort = sort) }
|
||||
context.putPrefInt(PreferKey.localBookImportSort, sort)
|
||||
ImportBookConfig.localBookImportSort = sort
|
||||
}
|
||||
|
||||
fun setSearchMode(isSearch: Boolean) {
|
||||
@@ -439,6 +547,22 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSingleToBookshelf(item: ImportBook) {
|
||||
if (item.isDir || item.isOnBookShelf) return
|
||||
execute {
|
||||
LocalBook.importFiles(listOf(item.file.uri))
|
||||
}.onError {
|
||||
context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
|
||||
AppLog.put("添加书架失败\n${it.localizedMessage}", it)
|
||||
}.onSuccess {
|
||||
context.toastOnUi("添加书架成功")
|
||||
}.onFinally {
|
||||
_state.update { state ->
|
||||
state.copy(selectedIds = state.selectedIds - item.selectionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onItemClick(item: ImportBook) {
|
||||
when {
|
||||
item.isDir -> navigateNext(item.file)
|
||||
@@ -506,12 +630,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
.filter { it.toString() in selectedIds }
|
||||
.forEach { it.delete() }
|
||||
}.onFinally {
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds },
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
clearSelection()
|
||||
refreshCurrentSource()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,6 +643,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
it.copy(
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet(),
|
||||
sourceMode = SourceMode.SCAN_RECURSIVE,
|
||||
interaction = it.interaction.copy(isLoading = true)
|
||||
)
|
||||
}
|
||||
@@ -534,6 +655,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = docs,
|
||||
selectedIds = emptySet(),
|
||||
interaction = it.interaction.copy(isLoading = false)
|
||||
)
|
||||
}
|
||||
@@ -562,22 +684,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
it.copy(
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet(),
|
||||
sourceMode = SourceMode.CURRENT_DIR,
|
||||
interaction = it.interaction.copy(isLoading = true)
|
||||
)
|
||||
}
|
||||
|
||||
execute {
|
||||
fileDoc.list { item ->
|
||||
when {
|
||||
item.name.startsWith(".") -> false
|
||||
item.isDir -> true
|
||||
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
|
||||
}
|
||||
} ?: emptyList()
|
||||
listCurrentDocDocs(fileDoc)
|
||||
}.onSuccess { docs ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = docs,
|
||||
selectedIds = emptySet(),
|
||||
interaction = it.interaction.copy(isLoading = false)
|
||||
)
|
||||
}
|
||||
@@ -589,6 +707,68 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listCurrentDocDocs(fileDoc: FileDoc): List<FileDoc> {
|
||||
return fileDoc.list { item ->
|
||||
when {
|
||||
item.name.startsWith(".") -> false
|
||||
item.isDir -> true
|
||||
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun refreshCurrentSource() {
|
||||
when (_state.value.sourceMode) {
|
||||
SourceMode.CURRENT_DIR -> loadCurrentDoc()
|
||||
SourceMode.SCAN_RECURSIVE -> scanCurrentDoc()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAutoSync() {
|
||||
if (autoSyncJob?.isActive == true) return
|
||||
autoSyncJob = viewModelScope.launch(IO) {
|
||||
while (isActive) {
|
||||
delay(1500)
|
||||
syncSourceDocs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSourceDocs() {
|
||||
if (scanDocJob?.isActive == true) return
|
||||
val snapshot = _state.value
|
||||
if (snapshot.interaction.isLoading || snapshot.sourceDocs.isEmpty()) return
|
||||
|
||||
val current = currentDoc(snapshot) ?: return
|
||||
when (snapshot.sourceMode) {
|
||||
SourceMode.CURRENT_DIR -> {
|
||||
val latestDocs = kotlin.runCatching { listCurrentDocDocs(current) }.getOrNull() ?: return
|
||||
val latestIds = latestDocs.asSequence().map { it.toString() }.toSet()
|
||||
val sourceIds = snapshot.sourceDocs.asSequence().map { it.toString() }.toSet()
|
||||
if (latestIds == sourceIds) return
|
||||
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
sourceDocs = latestDocs,
|
||||
selectedIds = state.selectedIds.filterTo(hashSetOf()) { it in latestIds }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SourceMode.SCAN_RECURSIVE -> {
|
||||
if (snapshot.sourceDocs.none { !it.exists() }) return
|
||||
val latestDocs = kotlin.runCatching { scanDoc(current) }.getOrNull() ?: return
|
||||
val latestIds = latestDocs.asSequence().map { it.toString() }.toSet()
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
sourceDocs = latestDocs,
|
||||
selectedIds = state.selectedIds.filterTo(hashSetOf()) { it in latestIds }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scanDoc(fileDoc: FileDoc): List<FileDoc> {
|
||||
val channel = Channel<FileDoc>(UNLIMITED)
|
||||
var n = 1
|
||||
@@ -617,6 +797,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
autoSyncJob?.cancel()
|
||||
scanDocJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.legado.app.ui.config.importBookConfig
|
||||
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.ui.config.prefDelegate
|
||||
|
||||
object ImportBookConfig {
|
||||
|
||||
var defaultBookTreeUri by prefDelegate(
|
||||
PreferKey.defaultBookTreeUri,
|
||||
null as String?
|
||||
)
|
||||
|
||||
var importBookPath by prefDelegate(
|
||||
PreferKey.importBookPath,
|
||||
null as String?
|
||||
)
|
||||
|
||||
var bookImportFileName by prefDelegate(
|
||||
PreferKey.bookImportFileName,
|
||||
null as String?
|
||||
)
|
||||
|
||||
var localBookImportSort by prefDelegate(
|
||||
PreferKey.localBookImportSort,
|
||||
0
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user