[优化] 新增书架界面的书籍管理

This commit is contained in:
HapeLee
2026-04-21 01:22:17 +08:00
parent d1a7007504
commit 5455600eba
3 changed files with 230 additions and 83 deletions
@@ -54,6 +54,7 @@ fun BookshelfItem(
cover: @Composable (Modifier) -> Unit, cover: @Composable (Modifier) -> Unit,
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isSelected: Boolean = false,
subTitle: String? = null, subTitle: String? = null,
desc: String? = null, desc: String? = null,
extra: @Composable (RowScope.() -> Unit)? = null, extra: @Composable (RowScope.() -> Unit)? = null,
@@ -64,10 +65,16 @@ fun BookshelfItem(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val selectedColor = if (isSelected) {
LegadoTheme.colorScheme.secondaryContainer.copy(alpha = 0.55f)
} else {
Color.Transparent
}
if (isGrid) { if (isGrid) {
Box( Box(
modifier = modifier modifier = modifier
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(selectedColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
@@ -138,6 +145,8 @@ fun BookshelfItem(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(selectedColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
@@ -377,6 +386,7 @@ fun BookItem(
book: BookShelfItem, book: BookShelfItem,
layoutMode: Int, layoutMode: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isSelected: Boolean = false,
gridStyle: Int = 0, gridStyle: Int = 0,
isCompact: Boolean = false, isCompact: Boolean = false,
isUpdating: Boolean = false, isUpdating: Boolean = false,
@@ -404,6 +414,7 @@ fun BookItem(
isGrid = layoutMode != 0, isGrid = layoutMode != 0,
gridStyle = gridStyle, gridStyle = gridStyle,
isCompact = isCompact, isCompact = isCompact,
isSelected = isSelected,
modifier = modifier, modifier = modifier,
cover = { modifier -> cover = { modifier ->
BookshelfCover( BookshelfCover(
@@ -23,6 +23,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -32,7 +33,7 @@ import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -67,7 +68,7 @@ import io.legado.app.R
import io.legado.app.base.BaseRuleEvent import io.legado.app.base.BaseRuleEvent
import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.book.manage.BookshelfManageActivity import io.legado.app.ui.book.info.GroupSelectSheet
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
@@ -77,8 +78,10 @@ import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab 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.card.NormalCard import io.legado.app.ui.widget.components.card.NormalCard
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.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
@@ -117,6 +120,9 @@ fun BookshelfScreen(
var showGroupManageSheet by remember { mutableStateOf(false) } var showGroupManageSheet by remember { mutableStateOf(false) }
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 isEditMode by remember { mutableStateOf(false) }
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -202,6 +208,50 @@ fun BookshelfScreen(
val bookGroupStyle = BookshelfConfig.bookGroupStyle val bookGroupStyle = BookshelfConfig.bookGroupStyle
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图 // 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) } var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
val clearSelection = {
selectedBookUrls = emptySet()
}
val exitEditMode = {
isEditMode = false
clearSelection()
}
val toggleEditMode = {
if (isEditMode) {
exitEditMode()
} else {
if (bookGroupStyle == 2 && isInFolderRoot) {
isInFolderRoot = false
}
isEditMode = true
clearSelection()
}
}
val toggleBookSelection: (String) -> Unit = { bookUrl ->
selectedBookUrls = if (selectedBookUrls.contains(bookUrl)) {
selectedBookUrls - bookUrl
} else {
selectedBookUrls + bookUrl
}
}
LaunchedEffect(pagerState.currentPage, isInFolderRoot) {
clearSelection()
}
LaunchedEffect(uiState.items) {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
}
BackHandler(enabled = isEditMode) {
if (selectedBookUrls.isNotEmpty()) {
clearSelection()
} else {
exitEditMode()
}
}
val baseTitle = when (bookGroupStyle) { val baseTitle = when (bookGroupStyle) {
1 -> { 1 -> {
uiState.groups.getOrNull(pagerState.currentPage)?.groupName uiState.groups.getOrNull(pagerState.currentPage)?.groupName
@@ -221,7 +271,7 @@ fun BookshelfScreen(
baseTitle baseTitle
} }
if (bookGroupStyle == 2 && !isInFolderRoot) { if (bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) {
BackHandler { BackHandler {
isInFolderRoot = true isInFolderRoot = true
} }
@@ -243,85 +293,113 @@ fun BookshelfScreen(
ListScaffold( ListScaffold(
title = title, title = title,
state = uiState, state = uiState,
onBackClick = if (isEditMode) exitEditMode else null,
backNavigationIcon = if (isEditMode) AppIcons.Close else AppIcons.Back,
showSearchAction = !isEditMode,
onSearchToggle = { context.startActivity<SearchActivity>() }, onSearchToggle = { context.startActivity<SearchActivity>() },
onSearchQueryChange = { viewModel.setSearchKey(it) }, onSearchQueryChange = { viewModel.setSearchKey(it) },
topBarActions = { }, topBarActions = {
dropDownMenuContent = { dismiss -> if (isEditMode) {
RoundDropdownMenuItem( TopBarActionButton(
text = stringResource(R.string.add_remote_book), onClick = {
onClick = { onNavigateToRemoteImport(); dismiss() }, selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
leadingIcon = { Icon(Icons.Default.Wifi, null) } },
) imageVector = Icons.Default.SelectAll,
RoundDropdownMenuItem( contentDescription = stringResource(R.string.select_all)
text = stringResource(R.string.book_local), )
onClick = { onNavigateToLocalImport(); dismiss() }, TopBarActionButton(
leadingIcon = { Icon(Icons.Default.Save, null) } onClick = {
) val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
RoundDropdownMenuItem( selectedBookUrls = visibleBookUrls - selectedBookUrls
text = stringResource(R.string.update_toc), },
onClick = { viewModel.upToc(uiState.items); dismiss() }, imageVector = Icons.Default.Refresh,
leadingIcon = { Icon(Icons.Default.Refresh, null) } contentDescription = stringResource(R.string.revert_selection)
) )
RoundDropdownMenuItem( TopBarActionButton(
text = stringResource(R.string.layout_setting), onClick = {
onClick = { showConfigSheet = true; dismiss() }, if (selectedBookUrls.isNotEmpty()) {
leadingIcon = { Icon(Icons.Default.GridView, null) } showGroupSelectSheet = true
) }
RoundDropdownMenuItem( },
text = stringResource(R.string.group_manage), imageVector = Icons.Default.Bookmarks,
onClick = { showGroupManageSheet = true; dismiss() }, contentDescription = stringResource(R.string.move_to_group)
leadingIcon = { Icon(Icons.Default.Edit, null) } )
) }
RoundDropdownMenuItem(
text = stringResource(R.string.add_url),
onClick = { showAddUrlDialog = true; dismiss() },
leadingIcon = { Icon(Icons.Default.Link, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.bookshelf_management),
onClick = {
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
context.startActivity<BookshelfManageActivity> {
putExtra("groupId", groupId)
}
dismiss()
},
leadingIcon = { Icon(Icons.Default.Settings, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.cache_export),
onClick = {
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
context.startActivity<CacheActivity> {
putExtra("groupId", groupId)
}
dismiss()
},
leadingIcon = { Icon(Icons.Default.Download, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_bookshelf),
onClick = {
showExportSheet = true
dismiss()
},
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.import_bookshelf),
onClick = { showImportSheet = true; dismiss() }
)
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showLogSheet = true
dismiss()
},
leadingIcon = { Icon(Icons.Default.History, null) }
)
}, },
dropDownMenuContent = if (!isEditMode) {
{ dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.add_remote_book),
onClick = { onNavigateToRemoteImport(); dismiss() },
leadingIcon = { Icon(Icons.Default.Wifi, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.book_local),
onClick = { onNavigateToLocalImport(); dismiss() },
leadingIcon = { Icon(Icons.Default.Save, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.update_toc),
onClick = { viewModel.upToc(uiState.items); dismiss() },
leadingIcon = { Icon(Icons.Default.Refresh, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.layout_setting),
onClick = { showConfigSheet = true; dismiss() },
leadingIcon = { Icon(Icons.Default.GridView, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.group_manage),
onClick = { showGroupManageSheet = true; dismiss() },
leadingIcon = { Icon(Icons.Default.Edit, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.add_url),
onClick = { showAddUrlDialog = true; dismiss() },
leadingIcon = { Icon(Icons.Default.Link, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.edit),
onClick = {
toggleEditMode()
dismiss()
},
leadingIcon = { Icon(Icons.Default.Edit, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.cache_export),
onClick = {
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
context.startActivity<CacheActivity> {
putExtra("groupId", groupId)
}
dismiss()
},
leadingIcon = { Icon(Icons.Default.Download, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_bookshelf),
onClick = {
showExportSheet = true
dismiss()
},
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.import_bookshelf),
onClick = { showImportSheet = true; dismiss() }
)
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showLogSheet = true
dismiss()
},
leadingIcon = { Icon(Icons.Default.History, null) }
)
}
} else null,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
bottomContent = if (bookGroupStyle == 0) { bottomContent = if (bookGroupStyle == 0) {
{ {
@@ -388,7 +466,7 @@ fun BookshelfScreen(
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val currentGroup = uiState.groups.getOrNull(pagerState.currentPage) val currentGroup = uiState.groups.getOrNull(pagerState.currentPage)
val pullToRefreshEnabled = currentGroup?.enableRefresh ?: true val pullToRefreshEnabled = (currentGroup?.enableRefresh ?: true) && !isEditMode
Box( Box(
modifier = Modifier modifier = Modifier
@@ -495,6 +573,9 @@ fun BookshelfScreen(
bookshelfLayoutMode = bookshelfLayoutMode, bookshelfLayoutMode = bookshelfLayoutMode,
bookshelfLayoutGrid = bookshelfLayoutGrid, bookshelfLayoutGrid = bookshelfLayoutGrid,
bookshelfLayoutList = bookshelfLayoutList, bookshelfLayoutList = bookshelfLayoutList,
isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
onBookClick = onBookClick, onBookClick = onBookClick,
onBookLongClick = onBookLongClick onBookLongClick = onBookLongClick
) )
@@ -523,6 +604,17 @@ fun BookshelfScreen(
onDismissRequest = { showGroupManageSheet = false } onDismissRequest = { showGroupManageSheet = false }
) )
GroupSelectSheet(
show = showGroupSelectSheet,
currentGroupId = 0L,
onDismissRequest = { showGroupSelectSheet = false },
onConfirm = { groupId ->
viewModel.moveBooksToGroup(selectedBookUrls, groupId)
showGroupSelectSheet = false
clearSelection()
}
)
SourceInputDialog( SourceInputDialog(
show = showAddUrlDialog, show = showAddUrlDialog,
title = stringResource(R.string.add_book_url), title = stringResource(R.string.add_book_url),
@@ -599,6 +691,9 @@ fun BookshelfPage(
bookshelfLayoutMode: Int, bookshelfLayoutMode: Int,
bookshelfLayoutGrid: Int, bookshelfLayoutGrid: Int,
bookshelfLayoutList: Int, bookshelfLayoutList: Int,
isEditMode: Boolean,
selectedBookUrls: Set<String>,
onToggleBookSelection: (BookShelfItem) -> Unit,
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit onBookLongClick: (BookShelfItem) -> Unit
) { ) {
@@ -636,10 +731,12 @@ fun BookshelfPage(
showFastScroll = BookshelfConfig.showBookshelfFastScroller showFastScroll = BookshelfConfig.showBookshelfFastScroller
) { ) {
items(books, key = { it.bookUrl }) { book -> items(books, key = { it.bookUrl }) { book ->
val isSelected = selectedBookUrls.contains(book.bookUrl)
BookItem( BookItem(
book = book, book = book,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
layoutMode = bookshelfLayoutMode, layoutMode = bookshelfLayoutMode,
isSelected = isSelected,
gridStyle = BookshelfConfig.bookshelfGridLayout, gridStyle = BookshelfConfig.bookshelfGridLayout,
isCompact = BookshelfConfig.bookshelfLayoutCompact, isCompact = BookshelfConfig.bookshelfLayoutCompact,
isUpdating = uiState.updatingBooks.contains(book.bookUrl), isUpdating = uiState.updatingBooks.contains(book.bookUrl),
@@ -647,10 +744,21 @@ fun BookshelfPage(
titleCenter = BookshelfConfig.bookshelfTitleCenter, titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines, titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow, coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = { onBookClick(book) }, onClick = {
onLongClick = { onBookLongClick(book) } if (isEditMode) {
onToggleBookSelection(book)
} else {
onBookClick(book)
}
},
onLongClick = {
if (isEditMode) {
onToggleBookSelection(book)
} else {
onBookLongClick(book)
}
}
) )
} }
} }
} }
@@ -363,6 +363,34 @@ class BookshelfViewModel(
refreshTrigger.value++ refreshTrigger.value++
} }
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())
}
}.onError {
context.toastOnUi("更新分组失败\n${it.localizedMessage}")
}
}
fun gotoTop() { fun gotoTop() {
scrollTrigger.tryEmit(Unit) scrollTrigger.tryEmit(Unit)
} }