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

This commit is contained in:
HapeLee
2026-04-26 15:13:50 +08:00
parent c1d9d57203
commit 1e3ea159e7
10 changed files with 1007 additions and 310 deletions
@@ -17,7 +17,11 @@ import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchRepositoryImpl import io.legado.app.data.repository.SearchRepositoryImpl
import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.help.coil.CoverFetcher import io.legado.app.help.coil.CoverFetcher
import io.legado.app.help.coil.CoverInterceptor import io.legado.app.help.coil.CoverInterceptor
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
@@ -67,6 +71,7 @@ val appModule = module {
single { get<AppDatabase>().bookDao } single { get<AppDatabase>().bookDao }
single { get<AppDatabase>().bookChapterDao } single { get<AppDatabase>().bookChapterDao }
single { get<AppDatabase>().bookGroupDao } single { get<AppDatabase>().bookGroupDao }
single { get<AppDatabase>().bookSourceDao }
singleOf(::ReadRecordRepository) singleOf(::ReadRecordRepository)
singleOf(::BookRepository) singleOf(::BookRepository)
@@ -74,6 +79,10 @@ val appModule = module {
singleOf(::SearchContentRepository) singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository) singleOf(::RemoteBookRepository)
singleOf(::ExploreKindUiUseCase) singleOf(::ExploreKindUiUseCase)
singleOf(::BatchCacheDownloadUseCase)
singleOf(::ClearBookCacheUseCase)
singleOf(::DeleteBooksUseCase)
singleOf(::UpdateBooksGroupUseCase)
singleOf(::CacheConfig) singleOf(::CacheConfig)
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
@@ -125,7 +134,19 @@ val appModule = module {
viewModelOf(::ExploreViewModel) viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel) viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel) viewModelOf(::SearchViewModel)
viewModelOf(::CacheViewModel) viewModel {
CacheViewModel(
application = get(),
bookDao = get(),
bookGroupDao = get(),
bookChapterDao = get(),
cacheConfig = get(),
batchCacheDownloadUseCase = get(),
clearBookCacheUseCase = get(),
deleteBooksUseCase = get(),
updateBooksGroupUseCase = get()
)
}
viewModel { (route: ReplaceEditRoute) -> viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel( ReplaceEditViewModel(
@@ -0,0 +1,63 @@
package io.legado.app.domain.usecase
import android.content.Context
import io.legado.app.constant.BookType
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.Book
import io.legado.app.model.CacheBook
class BatchCacheDownloadUseCase(
private val bookDao: BookDao
) {
suspend fun execute(
context: Context,
bookUrls: Set<String>,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean = false
): Int {
if (bookUrls.isEmpty()) return 0
var count = 0
bookUrls.forEach { bookUrl ->
val book = bookDao.getBook(bookUrl) ?: return@forEach
if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
count++
}
}
return count
}
fun execute(
context: Context,
books: List<Book>,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean = false
): Int {
if (books.isEmpty()) return 0
var count = 0
books.forEach { book ->
if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
count++
}
}
return count
}
private fun startIfNeeded(
context: Context,
book: Book,
downloadAllChapters: Boolean,
skipAudioBooks: Boolean
): Boolean {
if (book.type and BookType.local > 0) return false
if (skipAudioBooks && book.type and BookType.audio > 0) return false
val indices = if (downloadAllChapters) {
(0..book.lastChapterIndex).toList()
} else {
(book.durChapterIndex..book.lastChapterIndex).toList()
}
if (indices.isEmpty()) return false
CacheBook.start(context, book, indices)
return true
}
}
@@ -0,0 +1,22 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.Book
import io.legado.app.help.book.BookHelp
class ClearBookCacheUseCase(
private val bookDao: BookDao
) {
fun execute(book: Book): String {
BookHelp.clearCache(book)
return book.bookUrl
}
suspend fun execute(bookUrls: Set<String>): List<String> {
if (bookUrls.isEmpty()) return emptyList()
return bookUrls.mapNotNull { bookUrl ->
bookDao.getBook(bookUrl)?.let { execute(it) }
}
}
}
@@ -0,0 +1,33 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.help.book.isLocal
import io.legado.app.model.SourceCallBack
import io.legado.app.model.localBook.LocalBook
class DeleteBooksUseCase(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao,
private val bookSourceDao: BookSourceDao
) {
suspend fun execute(bookUrls: Set<String>, deleteOriginal: Boolean): List<String> {
if (bookUrls.isEmpty()) return emptyList()
val books = bookUrls.mapNotNull { bookDao.getBook(it) }
books.forEach { book ->
if (book.isLocal) {
LocalBook.deleteBook(book, deleteOriginal)
} else {
val source = bookSourceDao.getBookSource(book.origin)
SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, book)
}
bookChapterDao.delByBook(book.bookUrl)
}
if (books.isNotEmpty()) {
bookDao.delete(*books.toTypedArray())
}
return books.map { it.bookUrl }
}
}
@@ -0,0 +1,29 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookDao
class UpdateBooksGroupUseCase(
private val bookDao: BookDao
) {
suspend fun replaceGroup(bookUrls: Set<String>, groupId: Long) {
updateGroups(bookUrls) { groupId }
}
suspend fun updateGroups(bookUrls: Set<String>, transform: (Long) -> Long) {
if (bookUrls.isEmpty()) return
val updateBooks = bookUrls.mapNotNull { bookUrl ->
bookDao.getBook(bookUrl)?.let { book ->
val targetGroup = transform(book.group)
if (targetGroup == book.group) {
null
} else {
book.copy(group = targetGroup)
}
}
}
if (updateBooks.isNotEmpty()) {
bookDao.update(*updateBooks.toTypedArray())
}
}
}
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,10 @@ import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isAudio import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
@@ -61,6 +65,10 @@ sealed interface CacheIntent {
data class ToggleBookDownload(val book: Book) : CacheIntent data class ToggleBookDownload(val book: Book) : CacheIntent
data class DeleteBookDownload(val bookUrl: String) : CacheIntent data class DeleteBookDownload(val bookUrl: String) : CacheIntent
data class ClearBookCache(val book: Book) : CacheIntent data class ClearBookCache(val book: Book) : CacheIntent
data class MoveBooksToGroup(val bookUrls: Set<String>, val groupId: Long) : CacheIntent
data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : CacheIntent
data class ClearCachesForBooks(val bookUrls: Set<String>) : CacheIntent
data class DownloadBooks(val bookUrls: Set<String>, val downloadAllChapters: Boolean) : CacheIntent
data class SetExportUseReplace(val enabled: Boolean) : CacheIntent data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
@@ -83,7 +91,11 @@ class CacheViewModel(
private val bookDao: BookDao, private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao, private val bookGroupDao: BookGroupDao,
private val bookChapterDao: BookChapterDao, private val bookChapterDao: BookChapterDao,
val cacheConfig: CacheConfig val cacheConfig: CacheConfig,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val clearBookCacheUseCase: ClearBookCacheUseCase,
private val deleteBooksUseCase: DeleteBooksUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(CacheUiState()) private val _uiState = MutableStateFlow(CacheUiState())
@@ -110,6 +122,10 @@ class CacheViewModel(
is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book) is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl) is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book) is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book)
is CacheIntent.MoveBooksToGroup -> moveBooksToGroup(intent.bookUrls, intent.groupId)
is CacheIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
is CacheIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
is CacheIntent.DownloadBooks -> downloadBooks(intent.bookUrls, intent.downloadAllChapters)
is CacheIntent.SetExportUseReplace -> { is CacheIntent.SetExportUseReplace -> {
cacheConfig.exportUseReplace = intent.enabled cacheConfig.exportUseReplace = intent.enabled
syncExportConfig() syncExportConfig()
@@ -317,15 +333,16 @@ class CacheViewModel(
} }
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) { private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
books.forEach { book -> execute {
val indices = if (downloadAllChapters) { batchCacheDownloadUseCase.execute(
(0..book.lastChapterIndex).toList() context = context,
} else { books = books,
(book.durChapterIndex..book.lastChapterIndex).toList() downloadAllChapters = downloadAllChapters,
} skipAudioBooks = true
CacheBook.start(context, book, indices) )
}.onFinally {
syncDownloadRunning()
} }
syncDownloadRunning()
} }
private fun toggleBookDownload(book: Book) { private fun toggleBookDownload(book: Book) {
@@ -338,10 +355,68 @@ class CacheViewModel(
syncDownloadRunning() syncDownloadRunning()
} }
private fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
if (bookUrls.isEmpty()) return
val safeGroupId = groupId.coerceAtLeast(0L)
execute {
updateBooksGroupUseCase.replaceGroup(bookUrls, safeGroupId)
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("移动分组失败\n${it.localizedMessage}"))
}
}
private fun deleteBooks(bookUrls: Set<String>, deleteOriginal: Boolean) {
if (bookUrls.isEmpty()) return
execute {
deleteBooksUseCase.execute(bookUrls, deleteOriginal)
}.onSuccess { deletedBookUrls ->
deletedBookUrls.forEach { cacheChapters.remove(it) }
_effects.tryEmit(CacheEffect.ShowMessage("删除成功"))
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
}
}
private fun clearCachesForBooks(bookUrls: Set<String>) {
if (bookUrls.isEmpty()) return
execute {
clearBookCacheUseCase.execute(bookUrls)
}.onSuccess { clearedBookUrls ->
clearedBookUrls.forEach { bookUrl ->
cacheChapters[bookUrl] = hashSetOf()
emitBookChanged(bookUrl)
}
_effects.tryEmit(CacheEffect.ShowMessage("缓存已清理"))
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("清理缓存失败\n${it.localizedMessage}"))
}
}
private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
if (bookUrls.isEmpty()) return
execute {
batchCacheDownloadUseCase.execute(
context = context,
bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters,
skipAudioBooks = true
)
}.onSuccess { count ->
if (count > 0) {
_effects.tryEmit(CacheEffect.ShowMessage("已加入缓存队列: $count"))
} else {
_effects.tryEmit(CacheEffect.ShowMessage("没有可缓存的书籍"))
}
syncDownloadRunning()
}.onError {
_effects.tryEmit(CacheEffect.ShowMessage("批量缓存失败\n${it.localizedMessage}"))
}
}
private fun clearCacheForBook(book: Book) { private fun clearCacheForBook(book: Book) {
BookHelp.clearCache(book) val bookUrl = clearBookCacheUseCase.execute(book)
cacheChapters[book.bookUrl] = hashSetOf() cacheChapters[bookUrl] = hashSetOf()
emitBookChanged(book.bookUrl) emitBookChanged(bookUrl)
} }
private fun emitBookChanged(bookUrl: String) { private fun emitBookChanged(bookUrl: String) {
@@ -6,16 +6,20 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
@@ -79,13 +83,16 @@ import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.TopBarActionButton import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
import io.legado.app.ui.widget.components.list.ListScaffold import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -124,6 +131,7 @@ fun BookshelfScreen(
var showLogSheet by remember { mutableStateOf(false) } var showLogSheet by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) }
var showGroupSelectSheet by remember { mutableStateOf(false) } var showGroupSelectSheet by remember { mutableStateOf(false) }
var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
var isEditMode by remember { mutableStateOf(false) } var isEditMode by remember { mutableStateOf(false) }
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -289,18 +297,14 @@ fun BookshelfScreen(
else -> stringResource(R.string.bookshelf) else -> stringResource(R.string.bookshelf)
} }
val title = if (isEditMode) { val title = if (isEditMode) {
"$currentGroupBookCount · ${selectedBookUrls.size}" stringResource(R.string.bookshelf)
} else if (uiState.upBooksCount > 0) { } else if (uiState.upBooksCount > 0) {
"$baseTitle (${uiState.upBooksCount})" "$baseTitle (${uiState.upBooksCount})"
} else { } else {
baseTitle baseTitle
} }
val subtitle = if (isEditMode) { val subtitle = if (isEditMode) {
if (bookGroupStyle == 0) { "${allGroupsBookCount}"
"${allGroupsBookCount}"
} else {
"${currentGroupName ?: stringResource(R.string.bookshelf)} · 共${allGroupsBookCount}"
}
} else { } else {
null null
} }
@@ -323,13 +327,22 @@ fun BookshelfScreen(
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp
val gridContentHorizontalPadding = totalHorizontalPadding / 2 val gridContentHorizontalPadding = totalHorizontalPadding / 2
val gridInnerHorizontalPadding = totalHorizontalPadding / 2 val gridInnerHorizontalPadding = totalHorizontalPadding / 2
val currentMenuGroupId = if (uiState.isSearch) uiState.selectedGroupId else currentTabGroupId
val editStickySummary = if (isEditMode) {
BookshelfEditStickySummary(
selectedCount = selectedBookUrls.size,
currentGroupTotalCount = currentGroupBookCount,
groupName = currentGroupName,
showGroupName = bookGroupStyle != 0
)
} else {
null
}
ListScaffold( ListScaffold(
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
state = uiState, state = uiState,
onBackClick = if (isEditMode) exitEditMode else null,
backNavigationIcon = if (isEditMode) AppIcons.Close else AppIcons.Back,
showSearchAction = true, showSearchAction = true,
onSearchToggle = { active -> onSearchToggle = { active ->
viewModel.setSearchMode(active) viewModel.setSearchMode(active)
@@ -353,7 +366,7 @@ fun BookshelfScreen(
} }
}, },
topBarActions = { topBarActions = {
if (isEditMode) { AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -361,6 +374,8 @@ fun BookshelfScreen(
imageVector = Icons.Default.SelectAll, imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.select_all) contentDescription = stringResource(R.string.select_all)
) )
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -369,6 +384,19 @@ fun BookshelfScreen(
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.revert_selection) contentDescription = stringResource(R.string.revert_selection)
) )
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton(
onClick = {
if (selectedBookUrls.isNotEmpty()) {
showBatchDownloadConfirmDialog = true
}
},
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.action_download)
)
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
if (selectedBookUrls.isNotEmpty()) { if (selectedBookUrls.isNotEmpty()) {
@@ -719,6 +747,97 @@ fun BookshelfScreen(
} }
} }
TopFloatingStickyItem(
item = editStickySummary,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding() + 6.dp),
) { summary ->
Box {
NormalCard(
cornerRadius = 32.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer
) {
Row(
modifier = Modifier.padding(all = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextCard(
icon = AppIcons.Close,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
cornerRadius = 16.dp,
verticalPadding = 8.dp,
horizontalPadding = 8.dp,
onClick = exitEditMode
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "已选${summary.selectedCount}",
style = LegadoTheme.typography.labelSmallEmphasized
)
AppText(
text = " · 共${summary.currentGroupTotalCount}",
style = LegadoTheme.typography.labelSmallEmphasized
)
Spacer(modifier = Modifier.width(8.dp))
if (summary.showGroupName && !summary.groupName.isNullOrBlank()) {
TextCard(
text = summary.groupName,
textStyle = LegadoTheme.typography.labelSmallEmphasized,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
cornerRadius = 16.dp,
verticalPadding = 8.dp,
horizontalPadding = 12.dp,
onClick = { showGroupMenu = true }
)
}
}
}
if (summary.showGroupName) {
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
text = group.groupName,
onClick = {
val targetIndex =
uiState.groups.indexOfFirst { it.groupId == group.groupId }
if (targetIndex >= 0) {
scope.launch {
if (pagerState.currentPage != targetIndex) {
pagerState.animateScrollToPage(targetIndex)
}
}
}
if (uiState.isSearch || uiState.selectedGroupId != group.groupId) {
viewModel.changeGroup(group.groupId)
}
if (bookGroupStyle == 2) {
isInFolderRoot = false
}
dismiss()
},
trailingIcon = {
if (currentMenuGroupId == group.groupId) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
}
}
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@@ -794,6 +913,20 @@ fun BookshelfScreen(
onDismissRequest = { showLogSheet = false } onDismissRequest = { showLogSheet = false }
) )
AppAlertDialog(
show = showBatchDownloadConfirmDialog,
onDismissRequest = { showBatchDownloadConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showBatchDownloadConfirmDialog = false
viewModel.downloadBooks(selectedBookUrls)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showBatchDownloadConfirmDialog = false }
)
if (uiState.isLoading) { if (uiState.isLoading) {
Dialog(onDismissRequest = {}) { Dialog(onDismissRequest = {}) {
NormalCard( NormalCard(
@@ -818,6 +951,13 @@ fun BookshelfScreen(
} }
} }
private data class BookshelfEditStickySummary(
val selectedCount: Int,
val currentGroupTotalCount: Int,
val groupName: String?,
val showGroupName: Boolean,
)
@Composable @Composable
fun BookshelfPage( fun BookshelfPage(
paddingValues: PaddingValues, paddingValues: PaddingValues,
@@ -19,6 +19,8 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType import io.legado.app.help.book.addType
@@ -91,7 +93,9 @@ import kotlin.math.min
class BookshelfViewModel( class BookshelfViewModel(
application: Application, application: Application,
private val bookGroupRepository: BookGroupRepository, private val bookGroupRepository: BookGroupRepository,
private val uploadRepository: UploadRepository private val uploadRepository: UploadRepository,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
var addBookJob: Coroutine<*>? = null var addBookJob: Coroutine<*>? = null
@@ -377,33 +381,34 @@ class BookshelfViewModel(
} }
fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) { fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
updateBooksGroup(bookUrls) { groupId }
}
private fun updateBooksGroup(
bookUrls: Set<String>,
transform: (Long) -> Long
) {
if (bookUrls.isEmpty()) return if (bookUrls.isEmpty()) return
execute { execute {
val updateBooks = bookUrls.mapNotNull { url -> updateBooksGroupUseCase.replaceGroup(bookUrls, groupId)
appDb.bookDao.getBook(url)?.let { book ->
val targetGroup = transform(book.group)
if (targetGroup != book.group) {
book.copy(group = targetGroup)
} else {
null
}
}
}
if (updateBooks.isNotEmpty()) {
appDb.bookDao.update(*updateBooks.toTypedArray())
}
}.onError { }.onError {
context.toastOnUi("更新分组失败\n${it.localizedMessage}") context.toastOnUi("更新分组失败\n${it.localizedMessage}")
} }
} }
fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean = false) {
if (bookUrls.isEmpty()) return
execute {
batchCacheDownloadUseCase.execute(
context = context,
bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters,
skipAudioBooks = true
)
}.onSuccess { count ->
if (count > 0) {
context.toastOnUi("已加入缓存队列: $count")
} else {
context.toastOnUi(R.string.no_download)
}
}.onError {
context.toastOnUi("批量缓存失败\n${it.localizedMessage}")
}
}
fun gotoTop() { fun gotoTop() {
scrollTrigger.tryEmit(Unit) scrollTrigger.tryEmit(Unit)
} }
@@ -25,7 +25,7 @@ import io.legado.app.ui.widget.components.text.AnimatedTextLine
@Composable @Composable
fun TextCard( fun TextCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String, text: String? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
backgroundColor: Color? = null, backgroundColor: Color? = null,
@@ -65,16 +65,20 @@ fun TextCard(
tint = finalContentColor, tint = finalContentColor,
modifier = Modifier.size(iconSize) modifier = Modifier.size(iconSize)
) )
Spacer(modifier = Modifier.width(spacing))
} }
AnimatedTextLine( if (icon != null && text != null)
text = text, Spacer(modifier = Modifier.width(spacing))
style = textStyle,
color = finalContentColor, text?.let {
maxLines = 2, AnimatedTextLine(
overflow = TextOverflow.Ellipsis text = it,
) style = textStyle,
color = finalContentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }