From 1e3ea159e795b63f0a05c2ff942c36f1f4d565be Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:00:05 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=92=8C=E5=8E=9F=E6=9C=AC=E7=9A=84=E4=B9=A6?= =?UTF-8?q?=E6=9E=B6=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/io/legado/app/di/appModule.kt | 23 +- .../usecase/BatchCacheDownloadUseCase.kt | 63 ++ .../domain/usecase/ClearBookCacheUseCase.kt | 22 + .../app/domain/usecase/DeleteBooksUseCase.kt | 33 + .../domain/usecase/UpdateBooksGroupUseCase.kt | 29 + .../legado/app/ui/book/cache/CacheScreen.kt | 821 ++++++++++++------ .../app/ui/book/cache/CacheViewModel.kt | 99 ++- .../app/ui/main/bookshelf/BookshelfScreen.kt | 158 +++- .../ui/main/bookshelf/BookshelfViewModel.kt | 47 +- .../app/ui/widget/components/card/TextCard.kt | 22 +- 10 files changed, 1007 insertions(+), 310 deletions(-) create mode 100644 app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ClearBookCacheUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/DeleteBooksUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/UpdateBooksGroupUseCase.kt diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 0d214b679..5f7b02a39 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -17,7 +17,11 @@ import io.legado.app.data.repository.SearchRepository import io.legado.app.data.repository.SearchRepositoryImpl import io.legado.app.data.repository.SearchContentRepository 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.UpdateBooksGroupUseCase import io.legado.app.help.coil.CoverFetcher import io.legado.app.help.coil.CoverInterceptor import io.legado.app.help.http.okHttpClient @@ -67,6 +71,7 @@ val appModule = module { single { get().bookDao } single { get().bookChapterDao } single { get().bookGroupDao } + single { get().bookSourceDao } singleOf(::ReadRecordRepository) singleOf(::BookRepository) @@ -74,6 +79,10 @@ val appModule = module { singleOf(::SearchContentRepository) singleOf(::RemoteBookRepository) singleOf(::ExploreKindUiUseCase) + singleOf(::BatchCacheDownloadUseCase) + singleOf(::ClearBookCacheUseCase) + singleOf(::DeleteBooksUseCase) + singleOf(::UpdateBooksGroupUseCase) singleOf(::CacheConfig) single { DirectLinkUploadRepository() } @@ -125,7 +134,19 @@ val appModule = module { viewModelOf(::ExploreViewModel) viewModelOf(::RssViewModel) 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) -> ReplaceEditViewModel( diff --git a/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt new file mode 100644 index 000000000..7a2cb5e2e --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt @@ -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, + 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, + 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 + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ClearBookCacheUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ClearBookCacheUseCase.kt new file mode 100644 index 000000000..7882a24d5 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ClearBookCacheUseCase.kt @@ -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): List { + if (bookUrls.isEmpty()) return emptyList() + return bookUrls.mapNotNull { bookUrl -> + bookDao.getBook(bookUrl)?.let { execute(it) } + } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/DeleteBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/DeleteBooksUseCase.kt new file mode 100644 index 000000000..ffe9d8cec --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/DeleteBooksUseCase.kt @@ -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, deleteOriginal: Boolean): List { + 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 } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/UpdateBooksGroupUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/UpdateBooksGroupUseCase.kt new file mode 100644 index 000000000..67a81f735 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/UpdateBooksGroupUseCase.kt @@ -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, groupId: Long) { + updateGroups(bookUrls) { groupId } + } + + suspend fun updateGroups(bookUrls: Set, 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()) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt index dc49e96ca..55c6b4981 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt @@ -1,36 +1,58 @@ package io.legado.app.ui.book.cache +import androidx.activity.compose.BackHandler 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.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState 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.Download 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.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.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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.isLocal import io.legado.app.help.book.tryParesExportFileName -import io.legado.app.lib.dialogs.SelectItem import io.legado.app.service.ExportBookService 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.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.AppFloatingActionButton 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.TopBarActionButton -import io.legado.app.ui.widget.components.button.TopBarNavigationButton -import io.legado.app.ui.widget.components.card.GlassCard +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.PillDivider 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.RoundDropdownMenuItem 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.OptionSheet 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.FileDoc 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.toastOnUi 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 +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 = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + override val isSearch: Boolean = false, + override val isLoading: Boolean = false +) : ListUiState + @Composable fun CacheRouteScreen( groupId: Long, @@ -85,6 +119,7 @@ fun CacheRouteScreen( CacheScreen(viewModel = viewModel, onBackClick = onBackClick) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun CacheScreen( viewModel: CacheViewModel, @@ -92,18 +127,25 @@ private fun CacheScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) } var showFilePickerSheet by remember { mutableStateOf(false) } - var showDownloadConfirmDialog by remember { mutableStateOf(false) } var showDownloadAllConfirmDialog by remember { mutableStateOf(false) } + var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) } var showExportTypeDialog by remember { mutableStateOf(false) } var showExportFileNameDialog by remember { mutableStateOf(false) } var showCharsetDialog 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 pendingMoveGroupBookUrl by remember { mutableStateOf(null) } + var groupPickerCurrentGroupId by remember { mutableLongStateOf(0L) } + var moreMenuBookUrl by remember { mutableStateOf(null) } + var pendingDeleteBookUrls by remember { mutableStateOf>(emptySet()) } + var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } var pendingExportBookUrl by remember { mutableStateOf(null) } var pendingExportAll by remember { mutableStateOf(false) } + var pendingExportSelection by remember { mutableStateOf>(emptySet()) } var customExportPath by remember { mutableStateOf("") } var customExportBook by remember { mutableStateOf(null) } var customExportAllChapter by remember { mutableStateOf(false) } @@ -113,9 +155,13 @@ private fun CacheScreen( var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) } var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) } var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) } + var isSearchMode by remember { mutableStateOf(false) } + var searchKey by remember { mutableStateOf("") } + var selectedBookUrls by remember { mutableStateOf>(emptySet()) } + var deleteOriginalBookFile by remember { mutableStateOf(LocalConfig.deleteBookOriginal) } val exportBookPathKey = remember { "exportBookPath" } val exportTypes = remember { arrayListOf("txt", "epub") } - val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val focusRequester = remember { FocusRequester() } val exportFolderText = stringResource(R.string.export_folder) val exportAllText = stringResource(R.string.export_all) val exportChapterIndexText = stringResource(R.string.export_chapter_index) @@ -135,11 +181,72 @@ private fun CacheScreen( 书名:《{name}》-作者:{author}_备注 输出:书名:《三体》-作者:刘慈欣_备注 """.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 dirPath = "" - result.uri?.let { uri -> + uri?.let { if (uri.isContentScheme()) { ACache.get().put(exportBookPathKey, uri.toString()) dirPath = uri.toString() @@ -153,6 +260,14 @@ private fun CacheScreen( } } 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) { state.books.forEach { book -> startExport(context, dirPath, book, state.exportConfig.exportType) @@ -160,7 +275,7 @@ private fun CacheScreen( 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) { customExportPath = dirPath 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 = emptySet() + ) { pendingExportBookUrl = bookUrl pendingExportAll = forAll + pendingExportSelection = selection showFilePickerSheet = true } @@ -219,182 +339,280 @@ private fun CacheScreen( } } - AppScaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - GlassMediumFlexibleTopAppBar( - title = state.groupName ?: stringResource(R.string.offline_cache), - scrollBehavior = scrollBehavior, - navigationIcon = { - TopBarNavigationButton(onClick = onBackClick) - }, - actions = { - if (state.groupList.isNotEmpty()) { - TopBarActionButton( - onClick = { showGroupMenu = true }, - imageVector = AppIcons.Filter, - contentDescription = null - ) - RoundDropdownMenu( - expanded = showGroupMenu, - onDismissRequest = { showGroupMenu = false } - ) { dismiss -> - state.groupList.forEach { group -> - RoundDropdownMenuItem( - text = "${group.groupName}", - isSelected = group.groupId == state.groupId, - onClick = { - dismiss() - viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId)) - } - ) + fun exportSelected() { + if (selectedBookUrls.isEmpty()) return + val path = ACache.get().getAsString(exportBookPathKey) + if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) { + selectExportFolder(selection = selectedBookUrls) + } else { + selectedBookUrls.forEach { bookUrl -> + 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 } + }, + 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()) { + TopBarActionButton( + onClick = { showGroupMenu = true }, + imageVector = AppIcons.Filter, + contentDescription = null + ) + RoundDropdownMenu( + expanded = showGroupMenu, + onDismissRequest = { showGroupMenu = false } + ) { dismiss -> + state.groupList.forEach { group -> + RoundDropdownMenuItem( + text = group.groupName, + isSelected = group.groupId == state.groupId, + onClick = { + dismiss() + viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId)) } - } + ) } - TopBarActionButton( - onClick = { showMenu = true }, - imageVector = Icons.Default.MoreVert, - contentDescription = null + } + } + }, + dropDownMenuContent = { dismiss -> + RoundDropdownMenuItem( + text = stringResource(R.string.download_all), + onClick = { + dismiss() + if (state.isDownloadRunning) { + viewModel.dispatch(CacheIntent.StopDownload) + } else { + showDownloadAllConfirmDialog = true + } + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.export_all), + onClick = { dismiss(); exportAll() } + ) + PillDivider() + RoundDropdownMenuItem( + text = stringResource(R.string.export_folder), + onClick = { dismiss(); selectExportFolder() } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.export_file_name), + onClick = { + dismiss() + exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty() + showExportFileNameDialog = true + } + ) + RoundDropdownMenuItem( + text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})", + onClick = { + dismiss() + showExportTypeDialog = true + } + ) + RoundDropdownMenuItem( + text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})", + onClick = { + dismiss() + exportCharsetInput = state.exportConfig.exportCharset + showCharsetDialog = true + } + ) + PillDivider() + RoundDropdownMenuItem( + text = "替换净化", + isSelected = state.exportConfig.exportUseReplace, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace) ) - RoundDropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - RoundDropdownMenuItem( - text = stringResource(R.string.download_all), - onClick = { - showMenu = false - if (state.isDownloadRunning) { - viewModel.dispatch(CacheIntent.StopDownload) - } else { - showDownloadAllConfirmDialog = true - } - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.export_all), - onClick = { showMenu = false; exportAll() } - ) - PillDivider() - RoundDropdownMenuItem( - text = stringResource(R.string.export_folder), - onClick = { showMenu = false; selectExportFolder() } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.export_file_name), - onClick = { - showMenu = false - exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty() - showExportFileNameDialog = true - } - ) - RoundDropdownMenuItem( - text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})", - onClick = { - showMenu = false - showExportTypeDialog = true - } - ) - RoundDropdownMenuItem( - text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})", - onClick = { - showMenu = false - exportCharsetInput = state.exportConfig.exportCharset - showCharsetDialog = true - } - ) - PillDivider() - RoundDropdownMenuItem( - text = "替换净化", - isSelected = state.exportConfig.exportUseReplace, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace) - ) - } - ) - RoundDropdownMenuItem( - text = "自定义导出", - isSelected = state.exportConfig.enableCustomExport, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport) - ) - } - ) - RoundDropdownMenuItem( - text = "导出包含章节名", - isSelected = !state.exportConfig.exportNoChapterName, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName) - ) - } - ) - RoundDropdownMenuItem( - text = "导出到WebDav", - isSelected = state.exportConfig.exportToWebDav, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav) - ) - } - ) - RoundDropdownMenuItem( - text = "导出插图文件", - isSelected = state.exportConfig.exportPictureFile, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile) - ) - } - ) - RoundDropdownMenuItem( - text = "并行导出", - isSelected = state.exportConfig.parallelExportBook, - onClick = { - showMenu = false - viewModel.dispatch( - CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook) - ) - } - ) - PillDivider() - RoundDropdownMenuItem( - text = stringResource(R.string.log), - onClick = { - showMenu = false - showLogSheet = true - } - ) - } + } + ) + RoundDropdownMenuItem( + text = "自定义导出", + isSelected = state.exportConfig.enableCustomExport, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport) + ) + } + ) + RoundDropdownMenuItem( + text = "导出包含章节名", + isSelected = !state.exportConfig.exportNoChapterName, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName) + ) + } + ) + RoundDropdownMenuItem( + text = "导出到WebDav", + isSelected = state.exportConfig.exportToWebDav, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav) + ) + } + ) + RoundDropdownMenuItem( + text = "导出插图文件", + isSelected = state.exportConfig.exportPictureFile, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile) + ) + } + ) + RoundDropdownMenuItem( + text = "并行导出", + isSelected = state.exportConfig.parallelExportBook, + onClick = { + dismiss() + viewModel.dispatch( + CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook) + ) + } + ) + PillDivider() + RoundDropdownMenuItem( + text = stringResource(R.string.log), + onClick = { + dismiss() + showLogSheet = true } ) }, floatingActionButton = { - AppFloatingActionButton( - onClick = { - if (state.isDownloadRunning) { - viewModel.dispatch(CacheIntent.StopDownload) - } else { - showDownloadConfirmDialog = true + FloatingActionButtonMenu( + modifier = Modifier.offset(x = 16.dp, y = 16.dp), + expanded = fabMenuExpanded, + button = { + ToggleFloatingActionButton( + 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( - imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download, - contentDescription = null - ) + fabItems.forEach { (icon, label, action) -> + FloatingActionButtonMenuItem( + onClick = { + action() + fabMenuExpanded = false + }, + icon = { Icon(icon, contentDescription = null) }, + text = { Text(text = label) } + ) + } } } ) { paddingValues -> val renderVersion by rememberUpdatedState(state.cacheVersion) LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = 12.dp, @@ -404,93 +622,125 @@ private fun CacheScreen( ), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(state.books, key = { it.bookUrl }) { book -> + items(filteredBooks, key = { it.bookUrl }) { book -> val cacheCount = remember(renderVersion, book.bookUrl) { viewModel.getCacheChapters(book.bookUrl)?.size ?: 0 } val isDownloading = remember(renderVersion, book.bookUrl) { viewModel.isBookDownloading(book.bookUrl) } + val isSelected = selectedBookUrls.contains(book.bookUrl) val exportMsg = remember(renderVersion, book.bookUrl) { ExportBookService.exportMsg[book.bookUrl] } - val exportProgress = remember(renderVersion, book.bookUrl) { - ExportBookService.exportProgress[book.bookUrl] - } - GlassCard( - containerColor = LegadoTheme.colorScheme.surfaceContainerLow + NormalCard( + modifier = Modifier.fillMaxWidth(), + onClick = { toggleBookSelection(book) }, + onLongClick = { toggleBookSelection(book) }, + containerColor = if (isSelected) { + LegadoTheme.colorScheme.secondaryContainer + } else { + LegadoTheme.colorScheme.surfaceContainerLow + } ) { Column( modifier = Modifier .fillMaxWidth() - .padding(12.dp) + .adaptiveHorizontalPadding(vertical = 12.dp), + verticalArrangement = Arrangement.SpaceBetween ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - AppText( - text = book.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum)) - } - AppText( - text = stringResource(R.string.author_show, book.getRealAuthor()), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - AppText( - text = if (book.isLocal) { - stringResource(R.string.local_book) - } else { - stringResource(R.string.download_count, cacheCount, book.totalChapterNum) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.titleSmallEmphasized, + maxLines = 1 + ) + 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)) + } } - ) - if (isDownloading && book.totalChapterNum > 0) { - 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) + TextCard( + text = if (book.isLocal) { + stringResource(R.string.local_book) + } else { + stringResource( + R.string.download_count, + cacheCount, + book.totalChapterNum + ) + } ) } Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { - SmallTonalIconButton( - onClick = { - if (!book.isLocal) { - viewModel.dispatch(CacheIntent.ToggleBookDownload(book)) - } - }, - imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download, - contentDescription = null - ) - SmallTonalIconButton( - onClick = { exportBook(book) }, - modifier = Modifier.padding(start = 8.dp), - imageVector = Icons.Default.Upload, - contentDescription = null - ) - SmallTonalIconButton( - onClick = { - viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl)) - viewModel.dispatch(CacheIntent.ClearBookCache(book)) - }, - modifier = Modifier.padding(start = 8.dp), - imageVector = Icons.Default.Delete, - contentDescription = null - ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SmallTonalIconButton( + onClick = { + if (!book.isLocal) { + viewModel.dispatch(CacheIntent.ToggleBookDownload(book)) + } + }, + imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download, + contentDescription = "download" + ) + SmallTonalIconButton( + onClick = { exportBook(book) }, + imageVector = Icons.Default.Upload, + contentDescription = "upload" + ) + SmallTonalIconButton( + onClick = { + pendingMoveGroupBookUrl = book.bookUrl + groupPickerCurrentGroupId = book.group.coerceAtLeast(0L) + showGroupSelectSheet = true + }, + imageVector = Icons.Default.Bookmarks, + contentDescription = "group" + ) + 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, onSelectSysDir = { showFilePickerSheet = false - val default = arrayListOf>() - 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 - } + exportDir.launch(null) } ) AppAlertDialog( - show = showDownloadConfirmDialog, - onDismissRequest = { showDownloadConfirmDialog = false }, + show = showBatchDownloadConfirmDialog, + onDismissRequest = { showBatchDownloadConfirmDialog = false }, title = stringResource(R.string.draw), text = stringResource(R.string.sure_cache_book), confirmText = stringResource(android.R.string.ok), onConfirm = { - showDownloadConfirmDialog = false + showBatchDownloadConfirmDialog = false viewModel.dispatch( - CacheIntent.StartDownloadForVisibleBooks( - books = state.books, + CacheIntent.DownloadBooks( + bookUrls = selectedBookUrls, downloadAllChapters = false ) ) }, 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( diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt index d1a3d8af5..394f4c301 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt @@ -9,6 +9,10 @@ import io.legado.app.data.dao.BookGroupDao import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter 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.isAudio import io.legado.app.help.book.isLocal @@ -61,6 +65,10 @@ sealed interface CacheIntent { data class ToggleBookDownload(val book: Book) : CacheIntent data class DeleteBookDownload(val bookUrl: String) : CacheIntent data class ClearBookCache(val book: Book) : CacheIntent + data class MoveBooksToGroup(val bookUrls: Set, val groupId: Long) : CacheIntent + data class DeleteBooks(val bookUrls: Set, val deleteOriginal: Boolean) : CacheIntent + data class ClearCachesForBooks(val bookUrls: Set) : CacheIntent + data class DownloadBooks(val bookUrls: Set, val downloadAllChapters: Boolean) : CacheIntent data class SetExportUseReplace(val enabled: Boolean) : CacheIntent data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent @@ -83,7 +91,11 @@ class CacheViewModel( private val bookDao: BookDao, private val bookGroupDao: BookGroupDao, 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) { private val _uiState = MutableStateFlow(CacheUiState()) @@ -110,6 +122,10 @@ class CacheViewModel( is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book) is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl) 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 -> { cacheConfig.exportUseReplace = intent.enabled syncExportConfig() @@ -317,15 +333,16 @@ class CacheViewModel( } private fun startDownloadForVisibleBooks(books: List, downloadAllChapters: Boolean) { - books.forEach { book -> - val indices = if (downloadAllChapters) { - (0..book.lastChapterIndex).toList() - } else { - (book.durChapterIndex..book.lastChapterIndex).toList() - } - CacheBook.start(context, book, indices) + execute { + batchCacheDownloadUseCase.execute( + context = context, + books = books, + downloadAllChapters = downloadAllChapters, + skipAudioBooks = true + ) + }.onFinally { + syncDownloadRunning() } - syncDownloadRunning() } private fun toggleBookDownload(book: Book) { @@ -338,10 +355,68 @@ class CacheViewModel( syncDownloadRunning() } + private fun moveBooksToGroup(bookUrls: Set, 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, 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) { + 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, 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) { - BookHelp.clearCache(book) - cacheChapters[book.bookUrl] = hashSetOf() - emitBookChanged(book.bookUrl) + val bookUrl = clearBookCacheUseCase.execute(book) + cacheChapters[bookUrl] = hashSetOf() + emitBookChanged(bookUrl) } private fun emitBookChanged(bookUrl: String) { diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 2bbf2ae42..eccae4106 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -6,16 +6,20 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.items 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.button.SmallOutlinedIconToggleButton 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.TextCard 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.icon.AppIcons 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.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.RoundDropdownMenuItem import io.legado.app.ui.widget.components.tabRow.AppTabRow @@ -124,6 +131,7 @@ fun BookshelfScreen( var showLogSheet by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) } var showGroupSelectSheet by remember { mutableStateOf(false) } + var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) } var isEditMode by remember { mutableStateOf(false) } var selectedBookUrls by remember { mutableStateOf>(emptySet()) } @@ -289,18 +297,14 @@ fun BookshelfScreen( else -> stringResource(R.string.bookshelf) } val title = if (isEditMode) { - "$currentGroupBookCount · ${selectedBookUrls.size} 本" + stringResource(R.string.bookshelf) } else if (uiState.upBooksCount > 0) { "$baseTitle (${uiState.upBooksCount})" } else { baseTitle } val subtitle = if (isEditMode) { - if (bookGroupStyle == 0) { - "共${allGroupsBookCount}本" - } else { - "${currentGroupName ?: stringResource(R.string.bookshelf)} · 共${allGroupsBookCount}本" - } + "共${allGroupsBookCount}本" } else { null } @@ -323,13 +327,22 @@ fun BookshelfScreen( if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp val gridContentHorizontalPadding = 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( title = title, subtitle = subtitle, state = uiState, - onBackClick = if (isEditMode) exitEditMode else null, - backNavigationIcon = if (isEditMode) AppIcons.Close else AppIcons.Back, showSearchAction = true, onSearchToggle = { active -> viewModel.setSearchMode(active) @@ -353,7 +366,7 @@ fun BookshelfScreen( } }, topBarActions = { - if (isEditMode) { + AnimatedVisibility(visible = isEditMode) { TopBarActionButton( onClick = { selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } @@ -361,6 +374,8 @@ fun BookshelfScreen( imageVector = Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all) ) + } + AnimatedVisibility(visible = isEditMode) { TopBarActionButton( onClick = { val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } @@ -369,6 +384,19 @@ fun BookshelfScreen( imageVector = Icons.Default.Refresh, 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( onClick = { 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( state = pullToRefreshState, isRefreshing = isRefreshing, @@ -794,6 +913,20 @@ fun BookshelfScreen( 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) { Dialog(onDismissRequest = {}) { NormalCard( @@ -818,6 +951,13 @@ fun BookshelfScreen( } } +private data class BookshelfEditStickySummary( + val selectedCount: Int, + val currentGroupTotalCount: Int, + val groupName: String?, + val showGroupName: Boolean, +) + @Composable fun BookshelfPage( paddingValues: PaddingValues, diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index 3151ee60a..4fdbc0b37 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -19,6 +19,8 @@ import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.repository.BookGroupRepository 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.help.book.BookHelp import io.legado.app.help.book.addType @@ -91,7 +93,9 @@ import kotlin.math.min class BookshelfViewModel( application: Application, private val bookGroupRepository: BookGroupRepository, - private val uploadRepository: UploadRepository + private val uploadRepository: UploadRepository, + private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, + private val updateBooksGroupUseCase: UpdateBooksGroupUseCase ) : BaseViewModel(application) { var addBookJob: Coroutine<*>? = null @@ -377,33 +381,34 @@ class BookshelfViewModel( } fun moveBooksToGroup(bookUrls: Set, groupId: Long) { - updateBooksGroup(bookUrls) { groupId } - } - - private fun updateBooksGroup( - bookUrls: Set, - transform: (Long) -> Long - ) { if (bookUrls.isEmpty()) return execute { - val updateBooks = bookUrls.mapNotNull { url -> - 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()) - } + updateBooksGroupUseCase.replaceGroup(bookUrls, groupId) }.onError { context.toastOnUi("更新分组失败\n${it.localizedMessage}") } } + fun downloadBooks(bookUrls: Set, 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() { scrollTrigger.tryEmit(Unit) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt index 5f20daa6c..c0f2c37e0 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt @@ -25,7 +25,7 @@ import io.legado.app.ui.widget.components.text.AnimatedTextLine @Composable fun TextCard( modifier: Modifier = Modifier, - text: String, + text: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, backgroundColor: Color? = null, @@ -65,16 +65,20 @@ fun TextCard( tint = finalContentColor, modifier = Modifier.size(iconSize) ) - Spacer(modifier = Modifier.width(spacing)) } - AnimatedTextLine( - text = text, - style = textStyle, - color = finalContentColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + if (icon != null && text != null) + Spacer(modifier = Modifier.width(spacing)) + + text?.let { + AnimatedTextLine( + text = it, + style = textStyle, + color = finalContentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } }