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

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.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<AppDatabase>().bookDao }
single { get<AppDatabase>().bookChapterDao }
single { get<AppDatabase>().bookGroupDao }
single { get<AppDatabase>().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<UploadRepository> { 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(
@@ -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
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<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
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<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 pendingExportAll by remember { mutableStateOf(false) }
var pendingExportSelection by remember { mutableStateOf<Set<String>>(emptySet()) }
var customExportPath by remember { mutableStateOf("") }
var customExportBook by remember { mutableStateOf<Book?>(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<Set<String>>(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<String> = emptySet()
) {
pendingExportBookUrl = bookUrl
pendingExportAll = forAll
pendingExportSelection = selection
showFilePickerSheet = true
}
@@ -219,16 +339,99 @@ 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)
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 }
},
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()) {
TopBarActionButton(
onClick = { showGroupMenu = true },
@@ -241,7 +444,7 @@ private fun CacheScreen(
) { dismiss ->
state.groupList.forEach { group ->
RoundDropdownMenuItem(
text = "${group.groupName}",
text = group.groupName,
isSelected = group.groupId == state.groupId,
onClick = {
dismiss()
@@ -251,19 +454,12 @@ private fun CacheScreen(
}
}
}
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.download_all),
onClick = {
showMenu = false
dismiss()
if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload)
} else {
@@ -273,17 +469,17 @@ private fun CacheScreen(
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_all),
onClick = { showMenu = false; exportAll() }
onClick = { dismiss(); exportAll() }
)
PillDivider()
RoundDropdownMenuItem(
text = stringResource(R.string.export_folder),
onClick = { showMenu = false; selectExportFolder() }
onClick = { dismiss(); selectExportFolder() }
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_file_name),
onClick = {
showMenu = false
dismiss()
exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
showExportFileNameDialog = true
}
@@ -291,14 +487,14 @@ private fun CacheScreen(
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
onClick = {
showMenu = false
dismiss()
showExportTypeDialog = true
}
)
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
onClick = {
showMenu = false
dismiss()
exportCharsetInput = state.exportConfig.exportCharset
showCharsetDialog = true
}
@@ -308,7 +504,7 @@ private fun CacheScreen(
text = "替换净化",
isSelected = state.exportConfig.exportUseReplace,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
)
@@ -318,7 +514,7 @@ private fun CacheScreen(
text = "自定义导出",
isSelected = state.exportConfig.enableCustomExport,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
)
@@ -328,7 +524,7 @@ private fun CacheScreen(
text = "导出包含章节名",
isSelected = !state.exportConfig.exportNoChapterName,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
)
@@ -338,7 +534,7 @@ private fun CacheScreen(
text = "导出到WebDav",
isSelected = state.exportConfig.exportToWebDav,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
)
@@ -348,7 +544,7 @@ private fun CacheScreen(
text = "导出插图文件",
isSelected = state.exportConfig.exportPictureFile,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
)
@@ -358,7 +554,7 @@ private fun CacheScreen(
text = "并行导出",
isSelected = state.exportConfig.parallelExportBook,
onClick = {
showMenu = false
dismiss()
viewModel.dispatch(
CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
)
@@ -368,33 +564,55 @@ private fun CacheScreen(
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showMenu = false
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,69 +622,75 @@ 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]
NormalCard(
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(
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)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
AppText(
text = book.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
style = LegadoTheme.typography.titleSmallEmphasized,
maxLines = 1
)
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()),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppText(
}
TextCard(
text = if (book.isLocal) {
stringResource(R.string.local_book)
} else {
stringResource(R.string.download_count, cacheCount, book.totalChapterNum)
}
)
if (isDownloading && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
stringResource(
R.string.download_count,
cacheCount,
book.totalChapterNum
)
}
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(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SmallTonalIconButton(
onClick = {
if (!book.isLocal) {
@@ -474,23 +698,49 @@ private fun CacheScreen(
}
},
imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null
contentDescription = "download"
)
SmallTonalIconButton(
onClick = { exportBook(book) },
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Upload,
contentDescription = null
contentDescription = "upload"
)
SmallTonalIconButton(
onClick = {
viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl))
viewModel.dispatch(CacheIntent.ClearBookCache(book))
pendingMoveGroupBookUrl = book.bookUrl
groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
showGroupSelectSheet = true
},
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Delete,
contentDescription = null
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<SelectItem<Int>>()
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(
@@ -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<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 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,16 +333,17 @@ class CacheViewModel(
}
private fun startDownloadForVisibleBooks(books: List<Book>, 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()
}
}
private fun toggleBookDownload(book: Book) {
if (book.isLocal) return
@@ -338,10 +355,68 @@ class CacheViewModel(
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) {
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) {
@@ -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<Set<String>>(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}本"
}
} 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,
@@ -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<String>, groupId: Long) {
updateBooksGroup(bookUrls) { groupId }
}
private fun updateBooksGroup(
bookUrls: Set<String>,
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<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() {
scrollTrigger.tryEmit(Unit)
}
@@ -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,11 +65,14 @@ fun TextCard(
tint = finalContentColor,
modifier = Modifier.size(iconSize)
)
Spacer(modifier = Modifier.width(spacing))
}
if (icon != null && text != null)
Spacer(modifier = Modifier.width(spacing))
text?.let {
AnimatedTextLine(
text = text,
text = it,
style = textStyle,
color = finalContentColor,
maxLines = 2,
@@ -77,5 +80,6 @@ fun TextCard(
)
}
}
}
}