[优化] 合并下载和原本的书架管理界面

This commit is contained in:
HapeLee committed 2026-04-26 15:13:50 +08:00
1 parent c1d9d57203
commit 1e3ea159e7
10 files changed
+858 -161

No files matched your search

@@ -17,7 +17,11 @@ import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchRepositoryImpl import io.legado.app.data.repository.SearchRepositoryImpl
import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.help.coil.CoverFetcher import io.legado.app.help.coil.CoverFetcher
import io.legado.app.help.coil.CoverInterceptor import io.legado.app.help.coil.CoverInterceptor
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
@@ -67,6 +71,7 @@ val appModule = module {
single { get<AppDatabase>().bookDao } single { get<AppDatabase>().bookDao }
single { get<AppDatabase>().bookChapterDao } single { get<AppDatabase>().bookChapterDao }
single { get<AppDatabase>().bookGroupDao } single { get<AppDatabase>().bookGroupDao }
single { get<AppDatabase>().bookSourceDao }
singleOf(::ReadRecordRepository) singleOf(::ReadRecordRepository)
singleOf(::BookRepository) singleOf(::BookRepository)
@@ -74,6 +79,10 @@ val appModule = module {
singleOf(::SearchContentRepository) singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository) singleOf(::RemoteBookRepository)
singleOf(::ExploreKindUiUseCase) singleOf(::ExploreKindUiUseCase)
singleOf(::BatchCacheDownloadUseCase)
singleOf(::ClearBookCacheUseCase)
singleOf(::DeleteBooksUseCase)
singleOf(::UpdateBooksGroupUseCase)
singleOf(::CacheConfig) singleOf(::CacheConfig)
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
@@ -125,7 +134,19 @@ val appModule = module {
viewModelOf(::ExploreViewModel) viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel) viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel) viewModelOf(::SearchViewModel)
viewModelOf(::CacheViewModel) viewModel {
CacheViewModel(
application = get(),
bookDao = get(),
bookGroupDao = get(),
bookChapterDao = get(),
cacheConfig = get(),
batchCacheDownloadUseCase = get(),
clearBookCacheUseCase = get(),
deleteBooksUseCase = get(),
updateBooksGroupUseCase = get()
)
}
viewModel { (route: ReplaceEditRoute) -> viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel( ReplaceEditViewModel(
@@ -0,0 +1,63 @@
package io.legado.app.domain.usecase
import android.content.Context
import io.legado.app.constant.BookType
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.Book
import io.legado.app.model.CacheBook
class BatchCacheDownloadUseCase(
private val bookDao: BookDao
) {
suspend fun execute(
context: Context,
bookUrls: Set<String>,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean = false
): Int {
if (bookUrls.isEmpty()) return 0
var count = 0
bookUrls.forEach { bookUrl ->
val book = bookDao.getBook(bookUrl) ?: return@forEach
if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
count++
}
}
return count
}
fun execute(
context: Context,
books: List<Book>,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean = false
): Int {
if (books.isEmpty()) return 0
var count = 0
books.forEach { book ->
if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
count++
}
}
return count
}
private fun startIfNeeded(
context: Context,
book: Book,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean
): Boolean {
if (book.type and BookType.local > 0) return false
if (skipAudioBooks && book.type and BookType.audio > 0) return false
val indices = if (downloadAllChapters) {
(0..book.lastChapterIndex).toList()
} else {
(book.durChapterIndex..book.lastChapterIndex).toList()
}
if (indices.isEmpty()) return false
CacheBook.start(context, book, indices)
return true
}
}
@@ -0,0 +1,22 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.Book
import io.legado.app.help.book.BookHelp
class ClearBookCacheUseCase(
private val bookDao: BookDao
) {
fun execute(book: Book): String {
BookHelp.clearCache(book)
return book.bookUrl
}
suspend fun execute(bookUrls: Set<String>): List<String> {
if (bookUrls.isEmpty()) return emptyList()
return bookUrls.mapNotNull { bookUrl ->
bookDao.getBook(bookUrl)?.let { execute(it) }
}
}
}
@@ -0,0 +1,33 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.help.book.isLocal
import io.legado.app.model.SourceCallBack
import io.legado.app.model.localBook.LocalBook
class DeleteBooksUseCase(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao,
private val bookSourceDao: BookSourceDao
) {
suspend fun execute(bookUrls: Set<String>, deleteOriginal: Boolean): List<String> {
if (bookUrls.isEmpty()) return emptyList()
val books = bookUrls.mapNotNull { bookDao.getBook(it) }
books.forEach { book ->
if (book.isLocal) {
LocalBook.deleteBook(book, deleteOriginal)
} else {
val source = bookSourceDao.getBookSource(book.origin)
SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, book)
}
bookChapterDao.delByBook(book.bookUrl)
}
if (books.isNotEmpty()) {
bookDao.delete(*books.toTypedArray())
}
return books.map { it.bookUrl }
}
}
@@ -0,0 +1,29 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookDao
class UpdateBooksGroupUseCase(
private val bookDao: BookDao
) {
suspend fun replaceGroup(bookUrls: Set<String>, groupId: Long) {
updateGroups(bookUrls) { groupId }
}
suspend fun updateGroups(bookUrls: Set<String>, transform: (Long) -> Long) {
if (bookUrls.isEmpty()) return
val updateBooks = bookUrls.mapNotNull { bookUrl ->
bookDao.getBook(bookUrl)?.let { book ->
val targetGroup = transform(book.group)
if (targetGroup == book.group) {
null
} else {
book.copy(group = targetGroup)
}
}
}
if (updateBooks.isNotEmpty()) {
bookDao.update(*updateBooks.toTypedArray())
}
}
}
+421 -116
View File
@@ -1,36 +1,58 @@
package io.legado.app.ui.book.cache package io.legado.app.ui.book.cache
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuOpen
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButtonMenu
import androidx.compose.material3.FloatingActionButtonMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleFloatingActionButton
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
@@ -39,31 +61,27 @@ import io.legado.app.data.entities.Book
import io.legado.app.help.book.getExportFileName import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.tryParesExportFileName import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.service.ExportBookService import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.book.info.GroupSelectSheet
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallTonalIconButton 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.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton import io.legado.app.ui.widget.components.card.NormalCard
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.card.TextCard
import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.ListUiState
import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.utils.ACache import io.legado.app.utils.ACache
import io.legado.app.utils.FileDoc import io.legado.app.utils.FileDoc
import io.legado.app.utils.checkWrite import io.legado.app.utils.checkWrite
@@ -71,8 +89,24 @@ import io.legado.app.utils.isContentScheme
import io.legado.app.utils.startService import io.legado.app.utils.startService
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import io.legado.app.utils.verificationField import io.legado.app.utils.verificationField
import io.legado.app.help.config.LocalConfig
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
data class CacheFabAction(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val label: String,
val action: () -> Unit
)
private data class CacheListState(
override val items: List<Book> = emptyList(),
override val selectedIds: Set<Any> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false
) : ListUiState<Book>
@Composable @Composable
fun CacheRouteScreen( fun CacheRouteScreen(
groupId: Long, groupId: Long,
@@ -85,6 +119,7 @@ fun CacheRouteScreen(
CacheScreen(viewModel = viewModel, onBackClick = onBackClick) CacheScreen(viewModel = viewModel, onBackClick = onBackClick)
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun CacheScreen( private fun CacheScreen(
viewModel: CacheViewModel, viewModel: CacheViewModel,
@@ -92,18 +127,25 @@ private fun CacheScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var showMenu by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) } var showFilePickerSheet by remember { mutableStateOf(false) }
var showDownloadConfirmDialog by remember { mutableStateOf(false) }
var showDownloadAllConfirmDialog by remember { mutableStateOf(false) } var showDownloadAllConfirmDialog by remember { mutableStateOf(false) }
var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
var showExportTypeDialog by remember { mutableStateOf(false) } var showExportTypeDialog by remember { mutableStateOf(false) }
var showExportFileNameDialog by remember { mutableStateOf(false) } var showExportFileNameDialog by remember { mutableStateOf(false) }
var showCharsetDialog by remember { mutableStateOf(false) } var showCharsetDialog by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) } var showLogSheet by remember { mutableStateOf(false) }
var showGroupSelectSheet by remember { mutableStateOf(false) }
var showDeleteBookConfirmDialog by remember { mutableStateOf(false) }
var showCustomExportDialog by remember { mutableStateOf(false) } var showCustomExportDialog by remember { mutableStateOf(false) }
var pendingMoveGroupBookUrl by remember { mutableStateOf<String?>(null) }
var groupPickerCurrentGroupId by remember { mutableLongStateOf(0L) }
var moreMenuBookUrl by remember { mutableStateOf<String?>(null) }
var pendingDeleteBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
var pendingExportBookUrl by remember { mutableStateOf<String?>(null) } var pendingExportBookUrl by remember { mutableStateOf<String?>(null) }
var pendingExportAll by remember { mutableStateOf(false) } var pendingExportAll by remember { mutableStateOf(false) }
var pendingExportSelection by remember { mutableStateOf<Set<String>>(emptySet()) }
var customExportPath by remember { mutableStateOf("") } var customExportPath by remember { mutableStateOf("") }
var customExportBook by remember { mutableStateOf<Book?>(null) } var customExportBook by remember { mutableStateOf<Book?>(null) }
var customExportAllChapter by remember { mutableStateOf(false) } var customExportAllChapter by remember { mutableStateOf(false) }
@@ -113,9 +155,13 @@ private fun CacheScreen(
var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) } var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) }
var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) } var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) }
var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) } var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
var isSearchMode by remember { mutableStateOf(false) }
var searchKey by remember { mutableStateOf("") }
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
var deleteOriginalBookFile by remember { mutableStateOf(LocalConfig.deleteBookOriginal) }
val exportBookPathKey = remember { "exportBookPath" } val exportBookPathKey = remember { "exportBookPath" }
val exportTypes = remember { arrayListOf("txt", "epub") } val exportTypes = remember { arrayListOf("txt", "epub") }
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val focusRequester = remember { FocusRequester() }
val exportFolderText = stringResource(R.string.export_folder) val exportFolderText = stringResource(R.string.export_folder)
val exportAllText = stringResource(R.string.export_all) val exportAllText = stringResource(R.string.export_all)
val exportChapterIndexText = stringResource(R.string.export_chapter_index) val exportChapterIndexText = stringResource(R.string.export_chapter_index)
@@ -135,11 +181,72 @@ private fun CacheScreen(
书名:《{name}》-作者:{author}_备注 书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注 输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent() """.trimIndent()
val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
val exportDir = rememberLauncherForActivityResult(HandleFileContract()) { result -> val groupNameResolver: (Book) -> String = remember(userGroups, context) {
{ book ->
if (book.group <= 0L) {
context.getString(R.string.no_group)
} else {
val groups = userGroups.filter {
(book.group and it.groupId) > 0L
}
if (groups.isEmpty()) context.getString(R.string.no_group)
else groups.joinToString("、") { it.groupName }
}
}
}
val filteredBooks = remember(state.books, searchKey, isSearchMode, groupNameResolver) {
if (!isSearchMode || searchKey.isBlank()) {
state.books
} else {
val key = searchKey.trim()
state.books.filter { book ->
book.name.contains(key, true) ||
book.getRealAuthor().contains(key, true) ||
book.originName.contains(key, true) ||
groupNameResolver(book).contains(key, true)
}
}
}
val listUiState = remember(filteredBooks, selectedBookUrls, searchKey, isSearchMode) {
CacheListState(
items = filteredBooks,
selectedIds = selectedBookUrls.mapTo(linkedSetOf()) { it as Any },
searchKey = searchKey,
isSearch = isSearchMode,
isLoading = false
)
}
val inSelectionMode = selectedBookUrls.isNotEmpty()
val hasLocalBookInDeleteTarget = remember(state.books, pendingDeleteBookUrls) {
state.books.any { pendingDeleteBookUrls.contains(it.bookUrl) && it.isLocal }
}
val clearSelection = {
selectedBookUrls = emptySet()
}
val toggleBookSelection: (Book) -> Unit = { book ->
selectedBookUrls = if (selectedBookUrls.contains(book.bookUrl)) {
selectedBookUrls - book.bookUrl
} else {
selectedBookUrls + book.bookUrl
}
}
BackHandler(enabled = selectedBookUrls.isNotEmpty()) {
clearSelection()
}
LaunchedEffect(state.books) {
val visibleBookUrls = booksByUrl.keys
selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
}
val exportDir = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
var isReadyPath = false var isReadyPath = false
var dirPath = "" var dirPath = ""
result.uri?.let { uri -> uri?.let {
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString()) ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString() dirPath = uri.toString()
@@ -153,6 +260,14 @@ private fun CacheScreen(
} }
} }
if (!isReadyPath) return@rememberLauncherForActivityResult if (!isReadyPath) return@rememberLauncherForActivityResult
if (pendingExportSelection.isNotEmpty()) {
pendingExportSelection.forEach { bookUrl ->
booksByUrl[bookUrl]?.let { book ->
startExport(context, dirPath, book, state.exportConfig.exportType)
}
}
return@rememberLauncherForActivityResult
}
if (pendingExportAll) { if (pendingExportAll) {
state.books.forEach { book -> state.books.forEach { book ->
startExport(context, dirPath, book, state.exportConfig.exportType) startExport(context, dirPath, book, state.exportConfig.exportType)
@@ -160,7 +275,7 @@ private fun CacheScreen(
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
val book = state.books.firstOrNull { it.bookUrl == bookUrl } ?: return@rememberLauncherForActivityResult val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
if (state.exportConfig.enableCustomExport) { if (state.exportConfig.enableCustomExport) {
customExportPath = dirPath customExportPath = dirPath
customExportBook = book customExportBook = book
@@ -184,9 +299,14 @@ private fun CacheScreen(
} }
} }
fun selectExportFolder(bookUrl: String? = null, forAll: Boolean = false) { fun selectExportFolder(
bookUrl: String? = null,
forAll: Boolean = false,
selection: Set<String> = emptySet()
) {
pendingExportBookUrl = bookUrl pendingExportBookUrl = bookUrl
pendingExportAll = forAll pendingExportAll = forAll
pendingExportSelection = selection
showFilePickerSheet = true showFilePickerSheet = true
} }
@@ -219,16 +339,99 @@ private fun CacheScreen(
} }
} }
AppScaffold( fun exportSelected() {
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), if (selectedBookUrls.isEmpty()) return
topBar = { val path = ACache.get().getAsString(exportBookPathKey)
GlassMediumFlexibleTopAppBar( if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
title = state.groupName ?: stringResource(R.string.offline_cache), selectExportFolder(selection = selectedBookUrls)
scrollBehavior = scrollBehavior, } else {
navigationIcon = { selectedBookUrls.forEach { bookUrl ->
TopBarNavigationButton(onClick = onBackClick) booksByUrl[bookUrl]?.let { book ->
startExport(context, path, book, state.exportConfig.exportType)
}
}
}
}
fun resolveSelectionGroupMask(): Long {
val targetBooks = selectedBookUrls.mapNotNull { booksByUrl[it] }
if (targetBooks.isEmpty()) return 0L
val firstGroup = targetBooks.first().group.coerceAtLeast(0L)
return if (targetBooks.all { it.group == firstGroup }) firstGroup else 0L
}
val fabItems = listOf(
CacheFabAction(
Icons.Default.SelectAll,
stringResource(R.string.select_all)
) {
selectedBookUrls = state.books.mapTo(hashSetOf()) { it.bookUrl }
}, },
actions = { CacheFabAction(
Icons.Default.Refresh,
stringResource(R.string.revert_selection)
) {
val visibleBookUrls = booksByUrl.keys
selectedBookUrls = visibleBookUrls - selectedBookUrls
},
CacheFabAction(
Icons.Default.Download,
"缓存选中"
) {
if (selectedBookUrls.isNotEmpty()) {
showBatchDownloadConfirmDialog = true
}
},
CacheFabAction(
Icons.Default.Bookmarks,
stringResource(R.string.move_to_group)
) {
if (selectedBookUrls.isNotEmpty()) {
groupPickerCurrentGroupId = resolveSelectionGroupMask()
pendingMoveGroupBookUrl = null
showGroupSelectSheet = true
}
},
CacheFabAction(
Icons.Default.Upload,
"导出选中"
) {
exportSelected()
},
CacheFabAction(
Icons.Default.Delete,
stringResource(R.string.clear_cache)
) {
viewModel.dispatch(CacheIntent.ClearCachesForBooks(selectedBookUrls))
clearSelection()
},
CacheFabAction(
Icons.Default.Delete,
stringResource(R.string.delete)
) {
if (selectedBookUrls.isNotEmpty()) {
pendingDeleteBookUrls = selectedBookUrls
deleteOriginalBookFile = LocalConfig.deleteBookOriginal
showDeleteBookConfirmDialog = true
}
}
)
val listState = rememberLazyListState()
ListScaffold(
title = if (inSelectionMode) {
"已选 ${selectedBookUrls.size}/${filteredBooks.size}"
} else {
state.groupName ?: stringResource(R.string.offline_cache)
},
state = listUiState,
onBackClick = onBackClick,
onSearchToggle = { active ->
isSearchMode = active
if (!active) {
searchKey = ""
}
},
onSearchQueryChange = { searchKey = it },
searchPlaceholder = "筛选书名/作者/书源/分组",
topBarActions = {
if (state.groupList.isNotEmpty()) { if (state.groupList.isNotEmpty()) {
TopBarActionButton( TopBarActionButton(
onClick = { showGroupMenu = true }, onClick = { showGroupMenu = true },
@@ -241,7 +444,7 @@ private fun CacheScreen(
) { dismiss -> ) { dismiss ->
state.groupList.forEach { group -> state.groupList.forEach { group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = "${group.groupName}", text = group.groupName,
isSelected = group.groupId == state.groupId, isSelected = group.groupId == state.groupId,
onClick = { onClick = {
dismiss() dismiss()
@@ -251,19 +454,12 @@ private fun CacheScreen(
} }
} }
} }
TopBarActionButton( },
onClick = { showMenu = true }, dropDownMenuContent = { dismiss ->
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.download_all), text = stringResource(R.string.download_all),
onClick = { onClick = {
showMenu = false dismiss()
if (state.isDownloadRunning) { if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload) viewModel.dispatch(CacheIntent.StopDownload)
} else { } else {
@@ -273,17 +469,17 @@ private fun CacheScreen(
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.export_all), text = stringResource(R.string.export_all),
onClick = { showMenu = false; exportAll() } onClick = { dismiss(); exportAll() }
) )
PillDivider() PillDivider()
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.export_folder), text = stringResource(R.string.export_folder),
onClick = { showMenu = false; selectExportFolder() } onClick = { dismiss(); selectExportFolder() }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.export_file_name), text = stringResource(R.string.export_file_name),
onClick = { onClick = {
showMenu = false dismiss()
exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty() exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
showExportFileNameDialog = true showExportFileNameDialog = true
} }
@@ -291,14 +487,14 @@ private fun CacheScreen(
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})", text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
onClick = { onClick = {
showMenu = false dismiss()
showExportTypeDialog = true showExportTypeDialog = true
} }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})", text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
onClick = { onClick = {
showMenu = false dismiss()
exportCharsetInput = state.exportConfig.exportCharset exportCharsetInput = state.exportConfig.exportCharset
showCharsetDialog = true showCharsetDialog = true
} }
@@ -308,7 +504,7 @@ private fun CacheScreen(
text = "替换净化", text = "替换净化",
isSelected = state.exportConfig.exportUseReplace, isSelected = state.exportConfig.exportUseReplace,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace) CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
) )
@@ -318,7 +514,7 @@ private fun CacheScreen(
text = "自定义导出", text = "自定义导出",
isSelected = state.exportConfig.enableCustomExport, isSelected = state.exportConfig.enableCustomExport,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport) CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
) )
@@ -328,7 +524,7 @@ private fun CacheScreen(
text = "导出包含章节名", text = "导出包含章节名",
isSelected = !state.exportConfig.exportNoChapterName, isSelected = !state.exportConfig.exportNoChapterName,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName) CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
) )
@@ -338,7 +534,7 @@ private fun CacheScreen(
text = "导出到WebDav", text = "导出到WebDav",
isSelected = state.exportConfig.exportToWebDav, isSelected = state.exportConfig.exportToWebDav,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav) CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
) )
@@ -348,7 +544,7 @@ private fun CacheScreen(
text = "导出插图文件", text = "导出插图文件",
isSelected = state.exportConfig.exportPictureFile, isSelected = state.exportConfig.exportPictureFile,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile) CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
) )
@@ -358,7 +554,7 @@ private fun CacheScreen(
text = "并行导出", text = "并行导出",
isSelected = state.exportConfig.parallelExportBook, isSelected = state.exportConfig.parallelExportBook,
onClick = { onClick = {
showMenu = false dismiss()
viewModel.dispatch( viewModel.dispatch(
CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook) CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
) )
@@ -368,33 +564,55 @@ private fun CacheScreen(
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.log), text = stringResource(R.string.log),
onClick = { onClick = {
showMenu = false dismiss()
showLogSheet = true showLogSheet = true
} }
) )
}
}
)
}, },
floatingActionButton = { floatingActionButton = {
AppFloatingActionButton( FloatingActionButtonMenu(
onClick = { modifier = Modifier.offset(x = 16.dp, y = 16.dp),
if (state.isDownloadRunning) { expanded = fabMenuExpanded,
viewModel.dispatch(CacheIntent.StopDownload) button = {
} else { ToggleFloatingActionButton(
showDownloadConfirmDialog = true modifier = Modifier
.animateFloatingActionButton(
visible = true,
alignment = Alignment.BottomEnd,
)
.focusRequester(focusRequester),
checked = fabMenuExpanded,
onCheckedChange = { fabMenuExpanded = !fabMenuExpanded },
) {
val imageVector by remember {
derivedStateOf {
if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.AutoMirrored.Filled.MenuOpen
}
}
Icon(
imageVector = imageVector,
contentDescription = "Menu",
modifier = Modifier.animateIcon({ checkedProgress }),
)
} }
} }
) { ) {
Icon( fabItems.forEach { (icon, label, action) ->
imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download, FloatingActionButtonMenuItem(
contentDescription = null onClick = {
action()
fabMenuExpanded = false
},
icon = { Icon(icon, contentDescription = null) },
text = { Text(text = label) }
) )
} }
} }
}
) { paddingValues -> ) { paddingValues ->
val renderVersion by rememberUpdatedState(state.cacheVersion) val renderVersion by rememberUpdatedState(state.cacheVersion)
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 12.dp, start = 12.dp,
@@ -404,69 +622,75 @@ private fun CacheScreen(
), ),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(state.books, key = { it.bookUrl }) { book -> items(filteredBooks, key = { it.bookUrl }) { book ->
val cacheCount = remember(renderVersion, book.bookUrl) { val cacheCount = remember(renderVersion, book.bookUrl) {
viewModel.getCacheChapters(book.bookUrl)?.size ?: 0 viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
} }
val isDownloading = remember(renderVersion, book.bookUrl) { val isDownloading = remember(renderVersion, book.bookUrl) {
viewModel.isBookDownloading(book.bookUrl) viewModel.isBookDownloading(book.bookUrl)
} }
val isSelected = selectedBookUrls.contains(book.bookUrl)
val exportMsg = remember(renderVersion, book.bookUrl) { val exportMsg = remember(renderVersion, book.bookUrl) {
ExportBookService.exportMsg[book.bookUrl] ExportBookService.exportMsg[book.bookUrl]
} }
val exportProgress = remember(renderVersion, book.bookUrl) { NormalCard(
ExportBookService.exportProgress[book.bookUrl] modifier = Modifier.fillMaxWidth(),
onClick = { toggleBookSelection(book) },
onLongClick = { toggleBookSelection(book) },
containerColor = if (isSelected) {
LegadoTheme.colorScheme.secondaryContainer
} else {
LegadoTheme.colorScheme.surfaceContainerLow
} }
GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .adaptiveHorizontalPadding(vertical = 12.dp),
verticalArrangement = Arrangement.SpaceBetween
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AppText( AppText(
text = book.name, text = book.name,
maxLines = 1, style = LegadoTheme.typography.titleSmallEmphasized,
overflow = TextOverflow.Ellipsis, maxLines = 1
modifier = Modifier.weight(1f)
) )
TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum)) AppText(
text = book.getRealAuthor(),
style = LegadoTheme.typography.bodySmall
)
AppText(
text = "${groupNameResolver(book)} | ${book.originName.ifBlank { book.origin }}",
style = LegadoTheme.typography.labelSmallEmphasized.copy(color = LegadoTheme.colorScheme.primary)
)
if (exportMsg != null) {
AppText(text = exportMsg, modifier = Modifier.padding(top = 2.dp))
} }
AppText( }
text = stringResource(R.string.author_show, book.getRealAuthor()), TextCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppText(
text = if (book.isLocal) { text = if (book.isLocal) {
stringResource(R.string.local_book) stringResource(R.string.local_book)
} else { } else {
stringResource(R.string.download_count, cacheCount, book.totalChapterNum) stringResource(
} R.string.download_count,
) cacheCount,
if (isDownloading && book.totalChapterNum > 0) { book.totalChapterNum
AppLinearProgressIndicator(
progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) )
} }
if (exportMsg != null) {
AppText(text = exportMsg, modifier = Modifier.padding(top = 6.dp))
} else if (exportProgress != null && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (exportProgress.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
) )
} }
Row( Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SmallTonalIconButton( SmallTonalIconButton(
onClick = { onClick = {
if (!book.isLocal) { if (!book.isLocal) {
@@ -474,23 +698,49 @@ private fun CacheScreen(
} }
}, },
imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download, imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null contentDescription = "download"
) )
SmallTonalIconButton( SmallTonalIconButton(
onClick = { exportBook(book) }, onClick = { exportBook(book) },
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Upload, imageVector = Icons.Default.Upload,
contentDescription = null contentDescription = "upload"
) )
SmallTonalIconButton( SmallTonalIconButton(
onClick = { onClick = {
viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl)) pendingMoveGroupBookUrl = book.bookUrl
viewModel.dispatch(CacheIntent.ClearBookCache(book)) groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
showGroupSelectSheet = true
}, },
modifier = Modifier.padding(start = 8.dp), imageVector = Icons.Default.Bookmarks,
imageVector = Icons.Default.Delete, contentDescription = "group"
contentDescription = null
) )
SmallTonalIconButton(
onClick = { moreMenuBookUrl = book.bookUrl },
imageVector = Icons.Default.MoreVert,
contentDescription = "more"
)
RoundDropdownMenu(
expanded = moreMenuBookUrl == book.bookUrl,
onDismissRequest = { moreMenuBookUrl = null }
) { dismiss ->
RoundDropdownMenuItem(
text = "删除书籍",
onClick = {
pendingDeleteBookUrls = setOf(book.bookUrl)
deleteOriginalBookFile = LocalConfig.deleteBookOriginal
showDeleteBookConfirmDialog = true
dismiss()
}
)
RoundDropdownMenuItem(
text = "删除缓存",
onClick = {
viewModel.dispatch(CacheIntent.ClearCachesForBooks(setOf(book.bookUrl)))
dismiss()
}
)
}
}
} }
} }
} }
@@ -504,35 +754,90 @@ private fun CacheScreen(
title = exportFolderText, title = exportFolderText,
onSelectSysDir = { onSelectSysDir = {
showFilePickerSheet = false showFilePickerSheet = false
val default = arrayListOf<SelectItem<Int>>() exportDir.launch(null)
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) default.add(SelectItem(path, -1))
exportDir.launch {
mode = HandleFileContract.DIR_SYS
title = exportFolderText
otherActions = default
requestCode = 0
}
} }
) )
AppAlertDialog( AppAlertDialog(
show = showDownloadConfirmDialog, show = showBatchDownloadConfirmDialog,
onDismissRequest = { showDownloadConfirmDialog = false }, onDismissRequest = { showBatchDownloadConfirmDialog = false },
title = stringResource(R.string.draw), title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book), text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok), confirmText = stringResource(android.R.string.ok),
onConfirm = { onConfirm = {
showDownloadConfirmDialog = false showBatchDownloadConfirmDialog = false
viewModel.dispatch( viewModel.dispatch(
CacheIntent.StartDownloadForVisibleBooks( CacheIntent.DownloadBooks(
books = state.books, bookUrls = selectedBookUrls,
downloadAllChapters = false downloadAllChapters = false
) )
) )
}, },
dismissText = stringResource(android.R.string.cancel), dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDownloadConfirmDialog = false } onDismiss = { showBatchDownloadConfirmDialog = false }
)
AppAlertDialog(
show = showDeleteBookConfirmDialog,
onDismissRequest = { showDeleteBookConfirmDialog = false },
title = stringResource(R.string.draw),
content = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AppText(text = stringResource(R.string.sure_del))
if (hasLocalBookInDeleteTarget) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
Checkbox(
checked = deleteOriginalBookFile,
onCheckedChange = { checked ->
deleteOriginalBookFile = checked
}
)
AppText(
text = stringResource(R.string.delete_book_file),
modifier = Modifier.padding(top = 12.dp)
)
}
}
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDeleteBookConfirmDialog = false
LocalConfig.deleteBookOriginal = deleteOriginalBookFile
viewModel.dispatch(
CacheIntent.DeleteBooks(
bookUrls = pendingDeleteBookUrls,
deleteOriginal = deleteOriginalBookFile
)
)
pendingDeleteBookUrls = emptySet()
clearSelection()
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDeleteBookConfirmDialog = false }
)
GroupSelectSheet(
show = showGroupSelectSheet,
currentGroupId = groupPickerCurrentGroupId,
onDismissRequest = { showGroupSelectSheet = false },
onConfirm = { groupId ->
val moveSet = pendingMoveGroupBookUrl?.let { setOf(it) } ?: selectedBookUrls
val targetGroupId = groupId.coerceAtLeast(0L)
viewModel.dispatch(
CacheIntent.MoveBooksToGroup(
bookUrls = moveSet,
groupId = targetGroupId
)
)
pendingMoveGroupBookUrl = null
groupPickerCurrentGroupId = 0L
showGroupSelectSheet = false
clearSelection()
}
) )
AppAlertDialog( AppAlertDialog(
@@ -9,6 +9,10 @@ import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isAudio import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
@@ -61,6 +65,10 @@ sealed interface CacheIntent {
data class ToggleBookDownload(val book: Book) : CacheIntent data class ToggleBookDownload(val book: Book) : CacheIntent
data class DeleteBookDownload(val bookUrl: String) : CacheIntent data class DeleteBookDownload(val bookUrl: String) : CacheIntent
data class ClearBookCache(val book: Book) : CacheIntent data class ClearBookCache(val book: Book) : CacheIntent
data class MoveBooksToGroup(val bookUrls: Set<String>, val groupId: Long) : CacheIntent
data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : CacheIntent
data class ClearCachesForBooks(val bookUrls: Set<String>) : CacheIntent
data class DownloadBooks(val bookUrls: Set<String>, val downloadAllChapters: Boolean) : CacheIntent
data class SetExportUseReplace(val enabled: Boolean) : CacheIntent data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
@@ -83,7 +91,11 @@ class CacheViewModel(
private val bookDao: BookDao, private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao, private val bookGroupDao: BookGroupDao,
private val bookChapterDao: BookChapterDao, private val bookChapterDao: BookChapterDao,
val cacheConfig: CacheConfig val cacheConfig: CacheConfig,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val clearBookCacheUseCase: ClearBookCacheUseCase,
private val deleteBooksUseCase: DeleteBooksUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(CacheUiState()) private val _uiState = MutableStateFlow(CacheUiState())
@@ -110,6 +122,10 @@ class CacheViewModel(
is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book) is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl) is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book) is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book)
is CacheIntent.MoveBooksToGroup -> moveBooksToGroup(intent.bookUrls, intent.groupId)
is CacheIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
is CacheIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
is CacheIntent.DownloadBooks -> downloadBooks(intent.bookUrls, intent.downloadAllChapters)
is CacheIntent.SetExportUseReplace -> { is CacheIntent.SetExportUseReplace -> {
cacheConfig.exportUseReplace = intent.enabled cacheConfig.exportUseReplace = intent.enabled
syncExportConfig() syncExportConfig()
@@ -317,16 +333,17 @@ class CacheViewModel(
} }
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) { private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
books.forEach { book -> execute {
val indices = if (downloadAllChapters) { batchCacheDownloadUseCase.execute(
(0..book.lastChapterIndex).toList() context = context,
} else { books = books,
(book.durChapterIndex..book.lastChapterIndex).toList() downloadAllChapters = downloadAllChapters,
} skipAudioBooks = true
CacheBook.start(context, book, indices) )
} }.onFinally {
syncDownloadRunning() syncDownloadRunning()
} }
}
private fun toggleBookDownload(book: Book) { private fun toggleBookDownload(book: Book) {
if (book.isLocal) return if (book.isLocal) return
@@ -338,10 +355,68 @@ class CacheViewModel(
syncDownloadRunning() syncDownloadRunning()
} }
private fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
if (bookUrls.isEmpty()) return
val safeGroupId = groupId.coerceAtLeast(0L)
execute {
updateBooksGroupUseCase.replaceGroup(bookUrls, safeGroupId)
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("移动分组失败\n${it.localizedMessage}"))
}
}
private fun deleteBooks(bookUrls: Set<String>, deleteOriginal: Boolean) {
if (bookUrls.isEmpty()) return
execute {
deleteBooksUseCase.execute(bookUrls, deleteOriginal)
}.onSuccess { deletedBookUrls ->
deletedBookUrls.forEach { cacheChapters.remove(it) }
_effects.tryEmit(CacheEffect.ShowMessage("删除成功"))
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
}
}
private fun clearCachesForBooks(bookUrls: Set<String>) {
if (bookUrls.isEmpty()) return
execute {
clearBookCacheUseCase.execute(bookUrls)
}.onSuccess { clearedBookUrls ->
clearedBookUrls.forEach { bookUrl ->
cacheChapters[bookUrl] = hashSetOf()
emitBookChanged(bookUrl)
}
_effects.tryEmit(CacheEffect.ShowMessage("缓存已清理"))
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("清理缓存失败\n${it.localizedMessage}"))
}
}
private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
if (bookUrls.isEmpty()) return
execute {
batchCacheDownloadUseCase.execute(
context = context,
bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters,
skipAudioBooks = true
)
}.onSuccess { count ->
if (count > 0) {
_effects.tryEmit(CacheEffect.ShowMessage("已加入缓存队列: $count 本"))
} else {
_effects.tryEmit(CacheEffect.ShowMessage("没有可缓存的书籍"))
}
syncDownloadRunning()
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("批量缓存失败\n${it.localizedMessage}"))
}
}
private fun clearCacheForBook(book: Book) { private fun clearCacheForBook(book: Book) {
BookHelp.clearCache(book) val bookUrl = clearBookCacheUseCase.execute(book)
cacheChapters[book.bookUrl] = hashSetOf() cacheChapters[bookUrl] = hashSetOf()
emitBookChanged(book.bookUrl) emitBookChanged(bookUrl)
} }
private fun emitBookChanged(bookUrl: String) { private fun emitBookChanged(bookUrl: String) {
@@ -6,16 +6,20 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
@@ -79,13 +83,16 @@ import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.TopBarActionButton import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
import io.legado.app.ui.widget.components.list.ListScaffold import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -124,6 +131,7 @@ fun BookshelfScreen(
var showLogSheet by remember { mutableStateOf(false) } var showLogSheet by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) }
var showGroupSelectSheet by remember { mutableStateOf(false) } var showGroupSelectSheet by remember { mutableStateOf(false) }
var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
var isEditMode by remember { mutableStateOf(false) } var isEditMode by remember { mutableStateOf(false) }
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -289,18 +297,14 @@ fun BookshelfScreen(
else -> stringResource(R.string.bookshelf) else -> stringResource(R.string.bookshelf)
} }
val title = if (isEditMode) { val title = if (isEditMode) {
"$currentGroupBookCount · ${selectedBookUrls.size} 本" stringResource(R.string.bookshelf)
} else if (uiState.upBooksCount > 0) { } else if (uiState.upBooksCount > 0) {
"$baseTitle (${uiState.upBooksCount})" "$baseTitle (${uiState.upBooksCount})"
} else { } else {
baseTitle baseTitle
} }
val subtitle = if (isEditMode) { val subtitle = if (isEditMode) {
if (bookGroupStyle == 0) {
"共${allGroupsBookCount}本" "共${allGroupsBookCount}本"
} else {
"${currentGroupName ?: stringResource(R.string.bookshelf)} · 共${allGroupsBookCount}本"
}
} else { } else {
null null
} }
@@ -323,13 +327,22 @@ fun BookshelfScreen(
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp
val gridContentHorizontalPadding = totalHorizontalPadding / 2 val gridContentHorizontalPadding = totalHorizontalPadding / 2
val gridInnerHorizontalPadding = totalHorizontalPadding / 2 val gridInnerHorizontalPadding = totalHorizontalPadding / 2
val currentMenuGroupId = if (uiState.isSearch) uiState.selectedGroupId else currentTabGroupId
val editStickySummary = if (isEditMode) {
BookshelfEditStickySummary(
selectedCount = selectedBookUrls.size,
currentGroupTotalCount = currentGroupBookCount,
groupName = currentGroupName,
showGroupName = bookGroupStyle != 0
)
} else {
null
}
ListScaffold( ListScaffold(
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
state = uiState, state = uiState,
onBackClick = if (isEditMode) exitEditMode else null,
backNavigationIcon = if (isEditMode) AppIcons.Close else AppIcons.Back,
showSearchAction = true, showSearchAction = true,
onSearchToggle = { active -> onSearchToggle = { active ->
viewModel.setSearchMode(active) viewModel.setSearchMode(active)
@@ -353,7 +366,7 @@ fun BookshelfScreen(
} }
}, },
topBarActions = { topBarActions = {
if (isEditMode) { AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -361,6 +374,8 @@ fun BookshelfScreen(
imageVector = Icons.Default.SelectAll, imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.select_all) contentDescription = stringResource(R.string.select_all)
) )
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -369,6 +384,19 @@ fun BookshelfScreen(
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.revert_selection) contentDescription = stringResource(R.string.revert_selection)
) )
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton(
onClick = {
if (selectedBookUrls.isNotEmpty()) {
showBatchDownloadConfirmDialog = true
}
},
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.action_download)
)
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
if (selectedBookUrls.isNotEmpty()) { if (selectedBookUrls.isNotEmpty()) {
@@ -719,6 +747,97 @@ fun BookshelfScreen(
} }
} }
TopFloatingStickyItem(
item = editStickySummary,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding() + 6.dp),
) { summary ->
Box {
NormalCard(
cornerRadius = 32.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer
) {
Row(
modifier = Modifier.padding(all = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextCard(
icon = AppIcons.Close,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
cornerRadius = 16.dp,
verticalPadding = 8.dp,
horizontalPadding = 8.dp,
onClick = exitEditMode
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "已选${summary.selectedCount}本",
style = LegadoTheme.typography.labelSmallEmphasized
)
AppText(
text = " · 共${summary.currentGroupTotalCount}本",
style = LegadoTheme.typography.labelSmallEmphasized
)
Spacer(modifier = Modifier.width(8.dp))
if (summary.showGroupName && !summary.groupName.isNullOrBlank()) {
TextCard(
text = summary.groupName,
textStyle = LegadoTheme.typography.labelSmallEmphasized,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
cornerRadius = 16.dp,
verticalPadding = 8.dp,
horizontalPadding = 12.dp,
onClick = { showGroupMenu = true }
)
}
}
}
if (summary.showGroupName) {
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
text = group.groupName,
onClick = {
val targetIndex =
uiState.groups.indexOfFirst { it.groupId == group.groupId }
if (targetIndex >= 0) {
scope.launch {
if (pagerState.currentPage != targetIndex) {
pagerState.animateScrollToPage(targetIndex)
}
}
}
if (uiState.isSearch || uiState.selectedGroupId != group.groupId) {
viewModel.changeGroup(group.groupId)
}
if (bookGroupStyle == 2) {
isInFolderRoot = false
}
dismiss()
},
trailingIcon = {
if (currentMenuGroupId == group.groupId) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
}
}
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@@ -794,6 +913,20 @@ fun BookshelfScreen(
onDismissRequest = { showLogSheet = false } onDismissRequest = { showLogSheet = false }
) )
AppAlertDialog(
show = showBatchDownloadConfirmDialog,
onDismissRequest = { showBatchDownloadConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showBatchDownloadConfirmDialog = false
viewModel.downloadBooks(selectedBookUrls)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showBatchDownloadConfirmDialog = false }
)
if (uiState.isLoading) { if (uiState.isLoading) {
Dialog(onDismissRequest = {}) { Dialog(onDismissRequest = {}) {
NormalCard( NormalCard(
@@ -818,6 +951,13 @@ fun BookshelfScreen(
} }
} }
private data class BookshelfEditStickySummary(
val selectedCount: Int,
val currentGroupTotalCount: Int,
val groupName: String?,
val showGroupName: Boolean,
)
@Composable @Composable
fun BookshelfPage( fun BookshelfPage(
paddingValues: PaddingValues, paddingValues: PaddingValues,
@@ -19,6 +19,8 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType import io.legado.app.help.book.addType
@@ -91,7 +93,9 @@ import kotlin.math.min
class BookshelfViewModel( class BookshelfViewModel(
application: Application, application: Application,
private val bookGroupRepository: BookGroupRepository, private val bookGroupRepository: BookGroupRepository,
private val uploadRepository: UploadRepository private val uploadRepository: UploadRepository,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
var addBookJob: Coroutine<*>? = null var addBookJob: Coroutine<*>? = null
@@ -377,33 +381,34 @@ class BookshelfViewModel(
} }
fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) { fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
updateBooksGroup(bookUrls) { groupId }
}
private fun updateBooksGroup(
bookUrls: Set<String>,
transform: (Long) -> Long
) {
if (bookUrls.isEmpty()) return if (bookUrls.isEmpty()) return
execute { execute {
val updateBooks = bookUrls.mapNotNull { url -> updateBooksGroupUseCase.replaceGroup(bookUrls, groupId)
appDb.bookDao.getBook(url)?.let { book ->
val targetGroup = transform(book.group)
if (targetGroup != book.group) {
book.copy(group = targetGroup)
} else {
null
}
}
}
if (updateBooks.isNotEmpty()) {
appDb.bookDao.update(*updateBooks.toTypedArray())
}
}.onError { }.onError {
context.toastOnUi("更新分组失败\n${it.localizedMessage}") context.toastOnUi("更新分组失败\n${it.localizedMessage}")
} }
} }
fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean = false) {
if (bookUrls.isEmpty()) return
execute {
batchCacheDownloadUseCase.execute(
context = context,
bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters,
skipAudioBooks = true
)
}.onSuccess { count ->
if (count > 0) {
context.toastOnUi("已加入缓存队列: $count 本")
} else {
context.toastOnUi(R.string.no_download)
}
}.onError {
context.toastOnUi("批量缓存失败\n${it.localizedMessage}")
}
}
fun gotoTop() { fun gotoTop() {
scrollTrigger.tryEmit(Unit) scrollTrigger.tryEmit(Unit)
} }
@@ -25,7 +25,7 @@ import io.legado.app.ui.widget.components.text.AnimatedTextLine
@Composable @Composable
fun TextCard( fun TextCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String, text: String? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
backgroundColor: Color? = null, backgroundColor: Color? = null,
@@ -65,11 +65,14 @@ fun TextCard(
tint = finalContentColor, tint = finalContentColor,
modifier = Modifier.size(iconSize) modifier = Modifier.size(iconSize)
) )
Spacer(modifier = Modifier.width(spacing))
} }
if (icon != null && text != null)
Spacer(modifier = Modifier.width(spacing))
text?.let {
AnimatedTextLine( AnimatedTextLine(
text = text, text = it,
style = textStyle, style = textStyle,
color = finalContentColor, color = finalContentColor,
maxLines = 2, maxLines = 2,
@@ -77,5 +80,6 @@ fun TextCard(
) )
} }
} }
}
} }