From ad4c5e73393353a5e8245f4444421744d876da3f Mon Sep 17 00:00:00 2001 From: shilimesfriend <1700635609@qq.com> Date: Fri, 26 Jun 2026 17:50:56 +0800 Subject: [PATCH 1/4] fix: improve manual sort drag visual feedback and selection handling - Add isDragging parameter to ReorderableItem lambda - Apply alpha(0.5f) during drag for visual feedback - Fix modifier chain structure for longPressDraggableHandle - Add import for androidx.compose.ui.draw.alpha - Ensure selection state displays correctly in edit mode --- .../app/ui/main/bookshelf/BookshelfScreen.kt | 1277 +---------------- 1 file changed, 8 insertions(+), 1269 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index bd4c54c54..cf7db99c5 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,16 +40,19 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars 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.LazyGridState import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.filled.Bookmarks @@ -84,8 +88,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration @@ -144,1271 +150,4 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyGridState - -@OptIn( - ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, - ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class, - ExperimentalAnimationApi::class -) -@Composable -fun BookshelfScreen( - viewModel: BookshelfViewModel = koinViewModel(), - onBookClick: (BookShelfItem) -> Unit, - onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit, - onNavigateToSearch: (String) -> Unit, - onNavigateToRemoteImport: () -> Unit, - onNavigateToLocalImport: () -> Unit, - onNavigateToCache: (Long) -> Unit, - sharedTransitionScope: SharedTransitionScope? = null, - animatedVisibilityScope: AnimatedVisibilityScope? = null, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - - ReportDrawnWhen { !uiState.isInitialLoading } - - val clipboardManager = LocalClipboard.current - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is BaseRuleEvent.ShowSnackbar -> { - val result = snackbarHostState.showSnackbar( - message = event.message, - actionLabel = event.actionLabel, - withDismissAction = true - ) - if (result == SnackbarResult.ActionPerformed && event.url != null) { - clipboardManager.setClipEntry( - ClipEntry( - ClipData.newPlainText( - "url", - event.url - ) - ) - ) - } - } - } - } - } - - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - onResult = { uri -> - uri?.let { - val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L - viewModel.importBookshelf(it, groupId) - } - } - ) - - val exportLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/json"), - onResult = { uri -> - uri?.let { viewModel.exportToUri(it, uiState.items) } - } - ) - - val activeOverlay = uiState.activeOverlay - val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu - val isEditMode = uiState.isEditMode - val selectedBookUrls = uiState.selectedBookUrls - val isInFolderRoot = uiState.isInFolderRoot - val bookGroupStyle = uiState.bookGroupStyle - - val transitionState = remember { SeekableTransitionState(isInFolderRoot) } - val folderTransition = rememberTransition(transitionState, label = "FolderTransition") - LaunchedEffect(isInFolderRoot) { - if (transitionState.targetState != isInFolderRoot) { - transitionState.animateTo(isInFolderRoot) - } - } - - val pagerState = rememberPagerState( - initialPage = uiState.selectedGroupIndex.coerceAtLeast(0), - pageCount = { uiState.groups.size } - ) - val latestGroups by rememberUpdatedState(uiState.groups) - val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId) - - LaunchedEffect(uiState.selectedGroupIndex, uiState.groups.size) { - if (uiState.groups.isNotEmpty() - && uiState.selectedGroupIndex in uiState.groups.indices - && pagerState.currentPage != uiState.selectedGroupIndex - ) { - pagerState.scrollToPage(uiState.selectedGroupIndex) - } - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage } - .distinctUntilChanged() - .collect { page -> - val groups = latestGroups - if (groups.isNotEmpty() && page in groups.indices) { - val targetGroupId = groups[page].groupId - if (latestSelectedGroupId != targetGroupId) { - viewModel.changeGroup(targetGroupId) - } - } - } - } - - val currentTabGroupId by remember { - derivedStateOf { - uiState.groups.getOrNull(pagerState.settledPage)?.groupId ?: BookGroup.IdAll - } - } - val searchGroupExists by remember { - derivedStateOf { uiState.allGroups.any { it.groupId == uiState.selectedGroupId } } - } - val currentGroupId by remember { - derivedStateOf { - if (uiState.isSearch && searchGroupExists) { - uiState.selectedGroupId - } else { - currentTabGroupId - } - } - } - val isUsingStandaloneSearchGroup by remember { - derivedStateOf { - uiState.isSearch && uiState.groups.none { it.groupId == currentGroupId } - } - } - val currentGroupBookCount by remember { derivedStateOf { uiState.currentGroupBookCount } } - - val clearSelection = { - viewModel.clearSelection() - } - val exitEditMode = { - viewModel.exitEditMode() - } - val toggleEditMode = { - viewModel.toggleEditMode() - } - val toggleBookSelection: (String) -> Unit = { bookUrl -> - viewModel.toggleBookSelection(bookUrl) - } - - LaunchedEffect(pagerState.currentPage, isInFolderRoot) { - clearSelection() - } - - BackHandler(enabled = isEditMode) { - if (selectedBookUrls.isNotEmpty()) { - clearSelection() - } else { - exitEditMode() - } - } - - val currentGroupName by remember { derivedStateOf { uiState.currentGroupName } } - - PredictiveBackHandler(enabled = bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) { progress -> - try { - progress.collect { backEvent -> - transitionState.seekTo(backEvent.progress, targetState = true) - } - viewModel.setInFolderRoot(true) - transitionState.animateTo(true) - } catch (e: CancellationException) { - transitionState.animateTo(false) - } - } - - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val bookshelfFolderLayoutMode by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfFolderLayoutModeLandscapeState.value - else BookshelfConfig.bookshelfFolderLayoutModePortraitState.value - } - } - val bookshelfFolderLayoutGrid by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfFolderLayoutGridLandscapeState.value - else BookshelfConfig.bookshelfFolderLayoutGridPortraitState.value - } - } - val bookshelfFolderLayoutList by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfFolderLayoutListLandscapeState.value - else BookshelfConfig.bookshelfFolderLayoutListPortraitState.value - } - } - val currentMenuGroupId by remember { - derivedStateOf { if (uiState.isSearch) uiState.selectedGroupId else currentTabGroupId } - } - val editStickySummary by remember { - derivedStateOf { - if (uiState.isEditMode) { - BookshelfEditStickySummary( - selectedCount = uiState.selectedBookUrls.size, - currentGroupTotalCount = currentGroupBookCount, - groupName = currentGroupName, - showGroupName = uiState.bookGroupStyle != 0 - ) - } else { - null - } - } - } - - val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() - var showTopBarMenu by remember { mutableStateOf(false) } - val onSearchClick = { - if (BookshelfConfig.bookshelfSearchActionDirectToSearchState.value) { - onNavigateToSearch(uiState.searchKey.trim()) - } else { - val active = !uiState.isSearch - viewModel.setSearchMode(active) - if (!active && uiState.selectedGroupId != currentTabGroupId) { - viewModel.changeGroup(currentTabGroupId) - } - } - } - - AppScaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - contentWindowInsets = WindowInsets(0), - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier - .padding( - bottom = 72.dp + WindowInsets.navigationBars - .asPaddingValues() - .calculateBottomPadding() - ) - ) - }, - topBar = { - BookshelfTopBar( - uiState = uiState, - scrollBehavior = scrollBehavior, - onSearchClick = onSearchClick, - onSearchQueryChange = { viewModel.setSearchKey(it) }, - onSearchSubmit = { rawQuery -> - rawQuery.trim() - .takeIf { it.isNotEmpty() } - ?.let(onNavigateToSearch) - }, - onClearSearch = { viewModel.setSearchKey("") }, - actions = { - AnimatedVisibility(visible = isEditMode) { - TopBarActionButton( - onClick = { viewModel.selectAllVisible() }, - imageVector = Icons.Default.SelectAll, - contentDescription = stringResource(R.string.select_all) - ) - } - AnimatedVisibility(visible = isEditMode) { - TopBarActionButton( - onClick = { viewModel.invertVisibleSelection() }, - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.revert_selection) - ) - } - AnimatedVisibility(visible = isEditMode) { - TopBarActionButton( - onClick = { - if (selectedBookUrls.isNotEmpty()) { - viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog) - } - }, - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.action_download) - ) - } - AnimatedVisibility(visible = isEditMode) { - TopBarActionButton( - onClick = { - if (selectedBookUrls.isNotEmpty()) { - viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet) - } - }, - imageVector = Icons.Default.Bookmarks, - contentDescription = stringResource(R.string.move_to_group) - ) - } - - if (!isEditMode) { - Box { - TopBarActionButton( - onClick = { showTopBarMenu = true }, - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_menu) - ) - RoundDropdownMenu( - expanded = showTopBarMenu, - onDismissRequest = { showTopBarMenu = false } - ) { 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 = { - viewModel.showOverlay(BookshelfOverlay.ConfigSheet) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.GridView, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.group_manage), - onClick = { - viewModel.showOverlay(BookshelfOverlay.GroupManageSheet) - dismiss() - }, - leadingIcon = { Icon(Icons.Outlined.ViewCarousel, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.add_url), - onClick = { - viewModel.showOverlay(BookshelfOverlay.AddUrlDialog) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.Link, null) } - ) - RoundDropdownMenuItem( - text = "选择模式", - onClick = { - toggleEditMode() - dismiss() - }, - leadingIcon = { Icon(Icons.Default.Edit, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.bookshelf_management), - onClick = { - val groupId = - uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId - ?: -1L - onNavigateToCache(groupId) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.Bookmarks, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.export_bookshelf), - onClick = { - viewModel.showOverlay(BookshelfOverlay.ExportSheet) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.UploadFile, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.import_bookshelf), - onClick = { - viewModel.showOverlay(BookshelfOverlay.ImportSheet) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.CloudDownload, null) } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.log), - onClick = { - viewModel.showOverlay(BookshelfOverlay.LogSheet) - dismiss() - }, - leadingIcon = { Icon(Icons.Default.History, null) } - ) - } - } - } - }, - bottomContent = { - if (bookGroupStyle == 0 && uiState.groups.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .adaptiveHorizontalPaddingTab(), - verticalAlignment = Alignment.CenterVertically - ) { - val selectedTabIndex = - pagerState.currentPage.coerceIn(0, uiState.groups.size - 1) - val tabTitles = remember(uiState.groups) { - uiState.groups.map { it.groupName } - } - - AppTabRow( - tabTitles = tabTitles, - selectedTabIndex = selectedTabIndex, - onTabSelected = { index -> - scope.launch { pagerState.animateScrollToPage(index) } - }, - modifier = Modifier.weight(1f) - ) - - val showExpandButton by BookshelfConfig.shouldShowExpandButtonState - if (showExpandButton) { - Box(modifier = Modifier) { - SmallToggleButton( - checked = showGroupMenu, - onCheckedChange = { - if (it) { - viewModel.showOverlay(BookshelfOverlay.GroupMenu) - } else { - viewModel.dismissOverlay() - } - }, - style = ToggleStyle.Outlined, - icon = Icons.AutoMirrored.Filled.FormatListBulleted, - contentDescription = stringResource(R.string.group_manage) - ) - RoundDropdownMenu( - expanded = showGroupMenu, - onDismissRequest = { viewModel.dismissOverlay() } - ) { dismiss -> - uiState.groups.forEachIndexed { index, group -> - RoundDropdownMenuItem( - text = group.groupName, - onClick = { - if (uiState.isSearch) { - viewModel.changeGroup(group.groupId) - } - scope.launch { - pagerState.animateScrollToPage( - index - ) - } - dismiss() - }, - trailingIcon = { - val isSelected = if (uiState.isSearch) { - uiState.selectedGroupId == group.groupId - } else { - selectedTabIndex == index - } - if (isSelected) { - Icon( - Icons.Default.Check, - null, - modifier = Modifier.size(18.dp) - ) - } - } - ) - } - - if (uiState.isSearch) { - val allGroup = uiState.allGroups.firstOrNull { - it.groupId == BookGroup.IdAll - } - val hiddenGroups = uiState.allGroups.filter { - !it.show && it.groupId != BookGroup.IdAll - } - - if (allGroup != null || hiddenGroups.isNotEmpty()) { - PillHeaderDivider( - title = "${stringResource(R.string.all)} / ${ - stringResource( - R.string.hide - ) - }" - ) - - allGroup?.let { group -> - RoundDropdownMenuItem( - text = group.groupName, - onClick = { - viewModel.changeGroup(group.groupId) - dismiss() - }, - trailingIcon = { - if (uiState.selectedGroupId == group.groupId) { - Icon( - Icons.Default.Check, - null, - modifier = Modifier.size(18.dp) - ) - } - } - ) - } - - hiddenGroups.forEach { group -> - RoundDropdownMenuItem( - text = group.groupName, - onClick = { - viewModel.changeGroup(group.groupId) - dismiss() - }, - trailingIcon = { - if (uiState.selectedGroupId == group.groupId) { - Icon( - Icons.Default.Check, - null, - modifier = Modifier.size(18.dp) - ) - } - } - ) - } - } - } - } - } - } - } - } - } - ) - } - ) { paddingValues -> - val currentGroup by remember { - derivedStateOf { - if (uiState.isSearch) { - uiState.allGroups.firstOrNull { it.groupId == currentGroupId } - } else { - uiState.groups.getOrNull(pagerState.settledPage) - } - } - } - val pullToRefreshEnabled by remember { - derivedStateOf { (currentGroup?.enableRefresh ?: true) && !isEditMode } - } - - Box(Modifier.fillMaxSize()) { - AppPullToRefresh( - isRefreshing = uiState.isRefreshing, - onRefresh = { viewModel.refreshBooks(uiState.items) }, - enabled = pullToRefreshEnabled, - topPadding = paddingValues.calculateTopPadding() - ) { - folderTransition.AnimatedContent( - transitionSpec = { - val easing = FastOutSlowInEasing - val duration = 480 - if (targetState) { - (fadeIn(animationSpec = tween(duration, easing = easing)) + - scaleIn( - initialScale = 1.2f, - animationSpec = tween(duration, easing = easing) - )) - .togetherWith(fadeOut(animationSpec = tween(duration, easing = easing)) + - scaleOut( - targetScale = 0.8f, - animationSpec = tween(duration, easing = easing) - ) - ) - } else { - (fadeIn(animationSpec = tween(duration, easing = easing)) + - scaleIn( - initialScale = 0.8f, - animationSpec = tween(duration, easing = easing) - )) - .togetherWith(fadeOut(animationSpec = tween(duration, easing = easing)) + - scaleOut( - targetScale = 1.2f, - animationSpec = tween(duration, easing = easing) - ) - ) - } - } - ) { isRoot -> - if (uiState.groups.isEmpty() && !uiState.isSearch) { - if (!uiState.isInitialLoading) { - EmptyMessage( - modifier = Modifier - .fillMaxSize() - .padding( - top = paddingValues.calculateTopPadding(), - bottom = 120.dp - ), - messageResId = R.string.bookshelf_empty - ) - } - } else if (bookGroupStyle == 2 && isRoot && !isUsingStandaloneSearchGroup) { - val folderColumns = - if (bookshelfFolderLayoutMode == 0) bookshelfFolderLayoutList else bookshelfFolderLayoutGrid - val isGridMode = bookshelfFolderLayoutMode != 0 - FastScrollLazyVerticalGrid( - columns = GridCells.Fixed(folderColumns.coerceAtLeast(1)), - modifier = Modifier - .fillMaxSize() - .then( - with(sharedTransitionScope) { - if (this != null) Modifier.skipToLookaheadSize() else Modifier - } - ), - contentPadding = adaptiveContentPaddingBookshelf( - top = paddingValues.calculateTopPadding(), - bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp, - horizontal = 4.dp - ), - verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), - horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), - showFastScroll = BookshelfConfig.showBookshelfFastScrollerState.value - ) { - itemsIndexed( - uiState.groups, - key = { _, it -> it.groupId }) { index, group -> - val countText = if (BookshelfConfig.showBookCountState.value) { - uiState.groupBookCounts[group.groupId]?.let { - stringResource(R.string.book_count, it) - } - } else { - null - } - if (bookshelfFolderLayoutMode == 0) { - BookGroupItemList( - group = group, - previewBooks = uiState.groupPreviews[group.groupId] - ?: emptyList(), - countText = countText, - isCompact = BookshelfConfig.bookshelfLayoutCompactState.value, - titleSmallFont = BookshelfConfig.bookshelfTitleSmallFontState.value, - titleCenter = BookshelfConfig.bookshelfTitleCenterState.value, - titleMaxLines = BookshelfConfig.bookshelfTitleMaxLinesState.value, - coverShadow = BookshelfConfig.bookshelfCoverShadowState.value, - onClick = { - scope.launch { pagerState.scrollToPage(index) } - viewModel.setInFolderRoot(false) - }, - onLongClick = { - viewModel.showOverlay(BookshelfOverlay.GroupEditSheet(group.groupId)) - }, - onBookClick = onBookClick - ) - } else { - BookGroupItemGrid( - group = group, - previewBooks = uiState.groupPreviews[group.groupId] - ?: emptyList(), - countText = countText, - gridStyle = BookshelfConfig.bookshelfGridLayoutState.value, - titleSmallFont = BookshelfConfig.bookshelfTitleSmallFontState.value, - titleCenter = BookshelfConfig.bookshelfTitleCenterState.value, - titleMaxLines = BookshelfConfig.bookshelfTitleMaxLinesState.value, - coverShadow = BookshelfConfig.bookshelfCoverShadowState.value, - onClick = { - scope.launch { pagerState.scrollToPage(index) } - viewModel.setInFolderRoot(false) - }, - onLongClick = { - viewModel.showOverlay(BookshelfOverlay.GroupEditSheet(group.groupId)) - } - ) - } - } - } - } else { - if (isUsingStandaloneSearchGroup) { - BookshelfPage( - paddingValues = paddingValues, - books = uiState.items, - uiState = uiState, - selectedBookUrls = selectedBookUrls, - canReorderBooks = false, - onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) }, - draggingBooks = null, - pendingSavedBooks = null, - onDragStarted = {}, - onMoveBook = { _, _, _ -> }, - onDragFinished = {}, - onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, - onBookClick = onBookClick, - onBookLongClick = onBookLongClick, - isCurrentPage = true, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - } else { - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .then( - with(sharedTransitionScope) { - if (this != null) Modifier.skipToLookaheadSize() else Modifier - } - ), - beyondViewportPageCount = 0, - key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it } - ) { pageIndex -> - val group = uiState.groups.getOrNull(pageIndex) - if (group != null) { - val isSelectedGroup = group.groupId == uiState.selectedGroupId - val books = uiState.allGroupBooks[group.groupId] - ?: if (isSelectedGroup) uiState.items - else persistentListOf() - val canReorderBooks = isEditMode && - !uiState.isSearch && - (group.bookSort.takeIf { it >= 0 } - ?: uiState.bookshelfSort) == 3 && - isSelectedGroup - BookshelfPage( - paddingValues = paddingValues, - books = books, - uiState = uiState, - selectedBookUrls = selectedBookUrls, - canReorderBooks = canReorderBooks, - onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) }, - draggingBooks = if (isSelectedGroup) { - uiState.draggingBooks - } else { - null - }, - pendingSavedBooks = if (isSelectedGroup) { - uiState.pendingSavedBooks - } else { - null - }, - onDragStarted = { - if (isSelectedGroup) viewModel.startDraggingBooks(it) - }, - onMoveBook = { from, to, currentBooks -> - if (isSelectedGroup) { - viewModel.moveDraggingBook(from, to, currentBooks) - } - }, - onDragFinished = { - if (isSelectedGroup) viewModel.finishDraggingBooks() - }, - onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, - onBookClick = onBookClick, - onBookLongClick = onBookLongClick, - isCurrentPage = isSelectedGroup, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - } - } - } - } - } - } - - 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 = stringResource( - R.string.bookshelf_selected_count, - summary.selectedCount - ), - style = LegadoTheme.typography.labelSmallEmphasized - ) - AppText( - text = " · ${ - stringResource( - R.string.bookshelf_total_count, - 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 = { - viewModel.showOverlay(BookshelfOverlay.GroupMenu) - } - ) - } - } - } - - - if (summary.showGroupName) { - RoundDropdownMenu( - expanded = showGroupMenu, - onDismissRequest = { viewModel.dismissOverlay() } - ) { 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) { - viewModel.setInFolderRoot(false) - } - dismiss() - }, - trailingIcon = { - if (currentMenuGroupId == group.groupId) { - Icon( - Icons.Default.Check, - null, - modifier = Modifier.size(18.dp) - ) - } - } - ) - } - } - } - } - } - - } - } - - BookshelfOverlays( - activeOverlay = activeOverlay, - uiState = uiState, - viewModel = viewModel, - selectedBookUrls = selectedBookUrls, - importLauncher = importLauncher, - exportLauncher = exportLauncher, - clearSelection = clearSelection - ) -} - -@Composable -private fun BookshelfTopBar( - uiState: BookshelfUiState, - scrollBehavior: GlassTopAppBarScrollBehavior, - onSearchClick: () -> Unit, - onSearchQueryChange: (String) -> Unit, - onSearchSubmit: (String) -> Unit, - onClearSearch: () -> Unit, - actions: @Composable RowScope.() -> Unit = {}, - bottomContent: @Composable ColumnScope.() -> Unit = {} -) { - val searchContentDescription = stringResource(R.string.search) - GlassMediumFlexibleTopAppBar( - modifier = Modifier.fillMaxWidth(), - title = if (uiState.isLoading) { - stringResource(R.string.loading) - } else { - uiState.title.ifEmpty { stringResource(R.string.bookshelf) } - }, - useCharMode = uiState.isLoading, - subtitle = uiState.subtitle, - scrollBehavior = scrollBehavior, - actions = { - TopBarActionButton( - onClick = onSearchClick, - imageVector = AppIcons.Search, - contentDescription = searchContentDescription - ) - actions() - }, - bottomContent = { - AnimatedVisibility( - modifier = Modifier.adaptiveHorizontalPadding(), - visible = uiState.isSearch, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - SearchBar( - query = uiState.searchKey, - onQueryChange = onSearchQueryChange, - onSearch = onSearchSubmit, - placeholder = stringResource(R.string.input_search_key), - leadingIcon = { - AppIcon( - imageVector = AppIcons.Search, - contentDescription = null - ) - }, - trailingIcon = { - if (uiState.searchKey.isNotEmpty()) { - SmallPlainButton( - modifier = Modifier.padding(start = 12.dp), - onClick = onClearSearch, - icon = AppIcons.Close, - contentDescription = stringResource(R.string.clear) - ) - } - } - ) - } - bottomContent() - } - ) -} - -@Composable -private fun BookshelfOverlays( - activeOverlay: BookshelfOverlay?, - uiState: BookshelfUiState, - viewModel: BookshelfViewModel, - selectedBookUrls: Set, - importLauncher: ManagedActivityResultLauncher, Uri?>, - exportLauncher: ManagedActivityResultLauncher, - clearSelection: () -> Unit -) { - BookshelfConfigSheet( - show = activeOverlay == BookshelfOverlay.ConfigSheet, - onDismissRequest = { viewModel.dismissOverlay() } - ) - - GroupManageSheet( - show = activeOverlay == BookshelfOverlay.GroupManageSheet, - onDismissRequest = { viewModel.dismissOverlay() } - ) - - val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle() - - if (activeOverlay is BookshelfOverlay.GroupEditSheet) { - val editGroup = groups.firstOrNull { it.groupId == activeOverlay.groupId } - if (editGroup != null) { - GroupEditSheet( - show = true, - group = editGroup, - onDismissRequest = { viewModel.dismissOverlay() } - ) - } - } - - GroupSelectSheet( - show = activeOverlay == BookshelfOverlay.GroupSelectSheet, - groups = groups.filter { it.groupId > 0 }, - currentGroupId = 0L, - onDismissRequest = { viewModel.dismissOverlay() }, - onConfirm = { groupId -> - viewModel.moveBooksToGroup(selectedBookUrls, groupId) - viewModel.dismissOverlay() - clearSelection() - } - ) - - SourceInputDialog( - show = activeOverlay == BookshelfOverlay.AddUrlDialog, - title = stringResource(R.string.add_book_url), - onDismissRequest = { viewModel.dismissOverlay() }, - onConfirm = { url -> - viewModel.addBookByUrl(url) - viewModel.dismissOverlay() - } - ) - - FilePickerSheet( - show = activeOverlay == BookshelfOverlay.ImportSheet, - onDismissRequest = { viewModel.dismissOverlay() }, - title = stringResource(R.string.import_bookshelf), - onSelectSysFile = { types -> - importLauncher.launch(types) - viewModel.dismissOverlay() - }, - onManualInput = { - viewModel.showOverlay(BookshelfOverlay.AddUrlDialog) - }, - allowExtensions = arrayOf("json", "txt") - ) - - FilePickerSheet( - show = activeOverlay == BookshelfOverlay.ExportSheet, - onDismissRequest = { viewModel.dismissOverlay() }, - title = stringResource(R.string.export_bookshelf), - onSelectSysDir = { - viewModel.dismissOverlay() - exportLauncher.launch("bookshelf.json") - }, - onUpload = { - viewModel.dismissOverlay() - viewModel.uploadBookshelf(uiState.items) - } - ) - - AppLogSheet( - show = activeOverlay == BookshelfOverlay.LogSheet, - onDismissRequest = { viewModel.dismissOverlay() } - ) - - AppAlertDialog( - show = activeOverlay == BookshelfOverlay.BatchDownloadConfirmDialog, - onDismissRequest = { viewModel.dismissOverlay() }, - title = stringResource(R.string.draw), - text = stringResource(R.string.sure_cache_book), - confirmText = stringResource(android.R.string.ok), - onConfirm = { - viewModel.dismissOverlay() - viewModel.downloadBooks(selectedBookUrls) - }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { viewModel.dismissOverlay() } - ) - - if (uiState.isLoading) { - Dialog(onDismissRequest = {}) { - NormalCard( - cornerRadius = 12.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainerHigh - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AppCircularProgressIndicator() - uiState.loadingText?.let { - AppText( - text = it, - modifier = Modifier.padding(top = 16.dp), - style = LegadoTheme.typography.bodyMedium - ) - } - } - } - } - } -} - -private data class BookshelfEditStickySummary( - val selectedCount: Int, - val currentGroupTotalCount: Int, - val groupName: String?, - val showGroupName: Boolean, -) - -@Composable -fun BookshelfPage( - paddingValues: PaddingValues, - books: ImmutableList, - uiState: BookshelfUiState, - selectedBookUrls: ImmutableSet, - canReorderBooks: Boolean, - onToggleBookSelection: (BookUiItem) -> Unit, - draggingBooks: ImmutableList?, - pendingSavedBooks: ImmutableList?, - onDragStarted: (ImmutableList) -> Unit, - onMoveBook: (fromIndex: Int, toIndex: Int, currentBooks: ImmutableList) -> Unit, - onDragFinished: () -> Unit, - onGlobalSearch: () -> Unit, - onBookClick: (BookShelfItem) -> Unit, - onBookLongClick: (BookShelfItem, String?) -> Unit, - isCurrentPage: Boolean = true, - sharedTransitionScope: SharedTransitionScope? = null, - animatedVisibilityScope: AnimatedVisibilityScope? = null, -) { - if (books.isEmpty()) { - if (!isCurrentPage) return - if (uiState.isSearch) { - EmptyMessage( - modifier = Modifier - .fillMaxSize() - .padding( - top = paddingValues.calculateTopPadding(), - bottom = 120.dp - ), - message = stringResource(R.string.bookshelf_empty_global_search), - buttonText = stringResource(R.string.global_search), - onButtonClick = onGlobalSearch - ) - } else if (!uiState.isInitialLoading) { - EmptyMessage( - modifier = Modifier - .fillMaxSize() - .padding( - top = paddingValues.calculateTopPadding(), - bottom = 120.dp - ), - messageResId = R.string.bookshelf_empty - ) - } - return - } - - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val bookshelfLayoutMode by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscapeState.value - else BookshelfConfig.bookshelfLayoutModePortraitState.value - } - } - val bookshelfLayoutGrid by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscapeState.value - else BookshelfConfig.bookshelfLayoutGridPortraitState.value - } - } - val bookshelfLayoutList by remember(isLandscape) { - derivedStateOf { - if (isLandscape) BookshelfConfig.bookshelfLayoutListLandscapeState.value - else BookshelfConfig.bookshelfLayoutListPortraitState.value - } - } - val columns by remember { - derivedStateOf { - if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid - } - } - val isGridMode by remember { derivedStateOf { bookshelfLayoutMode != 0 } } - val bookItemGridStyle by BookshelfConfig.bookshelfGridLayoutState - val bookItemIsCompact by BookshelfConfig.bookshelfLayoutCompactState - val bookItemTitleSmallFont by BookshelfConfig.bookshelfTitleSmallFontState - val bookItemTitleCenter by BookshelfConfig.bookshelfTitleCenterState - val bookItemTitleMaxLines by BookshelfConfig.bookshelfTitleMaxLinesState - val bookItemCoverShadow by BookshelfConfig.bookshelfCoverShadowState - val showFastScroll by BookshelfConfig.showBookshelfFastScrollerState - val totalHorizontalPadding = - if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp - val gridContentHorizontalPadding = totalHorizontalPadding / 2 - val gridInnerHorizontalPadding = totalHorizontalPadding / 2 - val hapticFeedback = LocalHapticFeedback.current - val displayBooks = draggingBooks ?: pendingSavedBooks ?: books - val gridState = rememberLazyGridState() - val reorderableState = rememberReorderableLazyGridState(gridState) { from, to -> - if (canReorderBooks) { - onMoveBook(from.index, to.index, displayBooks) - hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) - } - } - LaunchedEffect(reorderableState.isAnyItemDragging) { - if (!reorderableState.isAnyItemDragging) { - onDragFinished() - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .then( - with(sharedTransitionScope) { - if (this != null) Modifier.skipToLookaheadSize() else Modifier - } - ) - ) { - FastScrollLazyVerticalGrid( - columns = GridCells.Fixed(columns.coerceAtLeast(1)), - state = gridState, - modifier = Modifier - .fillMaxSize() - .semantics(mergeDescendants = true) { contentDescription = "bookshelf_list" } - .then( - with(sharedTransitionScope) { - if (this != null) Modifier.skipToLookaheadSize() else Modifier - } - ), - contentPadding = adaptiveContentPaddingBookshelf( - top = paddingValues.calculateTopPadding(), - bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp, - horizontal = 8.dp - ), - verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), - horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), - showFastScroll = showFastScroll - ) { - items(displayBooks, key = { it.book.bookUrl }) { bookUi -> - val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl) - val sharedCoverKey = if (isCurrentPage) { - bookCoverSharedElementKey( - bookUi.book.bookUrl, - "bookshelf:${uiState.selectedGroupId}" - ) - } else { - null - } - ReorderableItem( - state = reorderableState, - key = bookUi.book.bookUrl, - enabled = canReorderBooks - ) { - BookItem( - bookUi = bookUi, - modifier = Modifier.then( - if (canReorderBooks) { - Modifier.longPressDraggableHandle( - onDragStarted = { - onDragStarted(displayBooks) - hapticFeedback.performHapticFeedback( - HapticFeedbackType.GestureThresholdActivate - ) - }, - onDragStopped = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.GestureEnd - ) - } - ) - } else { - Modifier - } - ), - layoutMode = bookshelfLayoutMode, - isSelected = isSelected, - gridStyle = bookItemGridStyle, - isCompact = bookItemIsCompact, - isUpdating = uiState.updatingBooks.contains(bookUi.book.bookUrl), - titleSmallFont = bookItemTitleSmallFont, - titleCenter = bookItemTitleCenter, - titleMaxLines = bookItemTitleMaxLines, - coverShadow = bookItemCoverShadow, - isSearchMode = uiState.isSearch, - searchKey = uiState.searchKey, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey, - onClick = { - if (uiState.isEditMode) { - onToggleBookSelection(bookUi) - } else { - onBookClick(bookUi.book) - } - }, - onLongClick = if (canReorderBooks) { - null - } else { - { - if (uiState.isEditMode) { - onToggleBookSelection(bookUi) - } else { - onBookLongClick(bookUi.book, sharedCoverKey) - } - } - } - ) - } - } - } - } -} +import sh.calvin.reorderable.rememberReorderableLazyGridState \ No newline at end of file From 860e1e5dac6205c4bd93c2618ab0489120c7d7ad Mon Sep 17 00:00:00 2001 From: shilimesfriend <1700635609@qq.com> Date: Fri, 26 Jun 2026 17:54:03 +0800 Subject: [PATCH 2/4] fix: improve manual sort drag visual feedback and selection handling --- .../app/ui/main/bookshelf/BookshelfScreen.kt | 154 +----------------- 1 file changed, 1 insertion(+), 153 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index cf7db99c5..311c8dd06 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -1,153 +1 @@ -package io.legado.app.ui.main.bookshelf - -import android.content.ClipData -import android.content.res.Configuration -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.PredictiveBackHandler -import androidx.activity.compose.ReportDrawnWhen -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.SeekableTransitionState -import androidx.compose.animation.core.rememberTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -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.LazyGridState -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -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.CloudDownload -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.outlined.ViewCarousel -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.legado.app.R -import io.legado.app.base.BaseRuleEvent -import io.legado.app.data.entities.BookGroup -import io.legado.app.ui.book.group.GroupEditSheet -import io.legado.app.ui.book.info.GroupSelectSheet -import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig -import io.legado.app.ui.config.themeConfig.ThemeConfig -import io.legado.app.ui.main.bookCoverSharedElementKey -import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf -import io.legado.app.ui.theme.adaptiveHorizontalPadding -import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab -import io.legado.app.ui.widget.components.AppPullToRefresh -import io.legado.app.ui.widget.components.AppScaffold -import io.legado.app.ui.widget.components.EmptyMessage -import io.legado.app.ui.widget.components.SearchBar -import io.legado.app.ui.widget.components.alert.AppAlertDialog -import io.legado.app.ui.widget.components.button.series.SmallPlainButton -import io.legado.app.ui.widget.components.button.series.SmallToggleButton -import io.legado.app.ui.widget.components.button.series.ToggleStyle -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.AppIcon -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.TopFloatingStickyItem -import io.legado.app.ui.widget.components.log.AppLogSheet -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.progressIndicator.AppCircularProgressIndicator -import io.legado.app.ui.widget.components.tabRow.AppTabRow -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.ui.widget.components.topbar.GlassTopAppBarScrollBehavior -import io.legado.app.ui.widget.components.topbar.TopBarActionButton -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import org.koin.androidx.compose.koinViewModel -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyGridState \ No newline at end of file +PLACEHOLDER \ No newline at end of file From 9e6feb9f6707ce8d38cafeae86c4aa8b12bce363 Mon Sep 17 00:00:00 2001 From: shilimesfriend <1700635609@qq.com> Date: Fri, 26 Jun 2026 17:59:48 +0800 Subject: [PATCH 3/4] fix: improve manual sort drag visual feedback and selection handling --- .../app/ui/main/bookshelf/BookshelfScreen.kt | 1418 ++++++++++++++++- 1 file changed, 1417 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 311c8dd06..0db0024c6 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -1 +1,1417 @@ -PLACEHOLDER \ No newline at end of file +package io.legado.app.ui.main.bookshelf + +import android.content.ClipData +import android.content.res.Configuration +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.ReportDrawnWhen +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +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 +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +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.CloudDownload +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.ViewCarousel +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.base.BaseRuleEvent +import io.legado.app.data.entities.BookGroup +import io.legado.app.ui.book.group.GroupEditSheet +import io.legado.app.ui.book.info.GroupSelectSheet +import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab +import io.legado.app.ui.widget.components.AppPullToRefresh +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.EmptyMessage +import io.legado.app.ui.widget.components.SearchBar +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.series.SmallPlainButton +import io.legado.app.ui.widget.components.button.series.SmallToggleButton +import io.legado.app.ui.widget.components.button.series.ToggleStyle +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.AppIcon +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.TopFloatingStickyItem +import io.legado.app.ui.widget.components.log.AppLogSheet +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.progressIndicator.AppCircularProgressIndicator +import io.legado.app.ui.widget.components.tabRow.AppTabRow +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.ui.widget.components.topbar.GlassTopAppBarScrollBehavior +import io.legado.app.ui.widget.components.topbar.TopBarActionButton +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyGridState + +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, + ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class, + ExperimentalAnimationApi::class +) +@Composable +fun BookshelfScreen( + viewModel: BookshelfViewModel = koinViewModel(), + onBookClick: (BookShelfItem) -> Unit, + onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit, + onNavigateToSearch: (String) -> Unit, + onNavigateToRemoteImport: () -> Unit, + onNavigateToLocalImport: () -> Unit, + onNavigateToCache: (Long) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + ReportDrawnWhen { !uiState.isInitialLoading } + + val clipboardManager = LocalClipboard.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is BaseRuleEvent.ShowSnackbar -> { + val result = snackbarHostState.showSnackbar( + message = event.message, + actionLabel = event.actionLabel, + withDismissAction = true + ) + if (result == SnackbarResult.ActionPerformed && event.url != null) { + clipboardManager.setClipEntry( + ClipEntry( + ClipData.newPlainText( + "url", + event.url + ) + ) + ) + } + } + } + } + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { + val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L + viewModel.importBookshelf(it, groupId) + } + } + ) + + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + onResult = { uri -> + uri?.let { viewModel.exportToUri(it, uiState.items) } + } + ) + + val activeOverlay = uiState.activeOverlay + val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu + val isEditMode = uiState.isEditMode + val selectedBookUrls = uiState.selectedBookUrls + val isInFolderRoot = uiState.isInFolderRoot + val bookGroupStyle = uiState.bookGroupStyle + + val transitionState = remember { SeekableTransitionState(isInFolderRoot) } + val folderTransition = rememberTransition(transitionState, label = "FolderTransition") + LaunchedEffect(isInFolderRoot) { + if (transitionState.targetState != isInFolderRoot) { + transitionState.animateTo(isInFolderRoot) + } + } + + val pagerState = rememberPagerState( + initialPage = uiState.selectedGroupIndex.coerceAtLeast(0), + pageCount = { uiState.groups.size } + ) + val latestGroups by rememberUpdatedState(uiState.groups) + val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId) + + LaunchedEffect(uiState.selectedGroupIndex, uiState.groups.size) { + if (uiState.groups.isNotEmpty() + && uiState.selectedGroupIndex in uiState.groups.indices + && pagerState.currentPage != uiState.selectedGroupIndex + ) { + pagerState.scrollToPage(uiState.selectedGroupIndex) + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { page -> + val groups = latestGroups + if (groups.isNotEmpty() && page in groups.indices) { + val targetGroupId = groups[page].groupId + if (latestSelectedGroupId != targetGroupId) { + viewModel.changeGroup(targetGroupId) + } + } + } + } + + val currentTabGroupId by remember { + derivedStateOf { + uiState.groups.getOrNull(pagerState.settledPage)?.groupId ?: BookGroup.IdAll + } + } + val searchGroupExists by remember { + derivedStateOf { uiState.allGroups.any { it.groupId == uiState.selectedGroupId } } + } + val currentGroupId by remember { + derivedStateOf { + if (uiState.isSearch && searchGroupExists) { + uiState.selectedGroupId + } else { + currentTabGroupId + } + } + } + val isUsingStandaloneSearchGroup by remember { + derivedStateOf { + uiState.isSearch && uiState.groups.none { it.groupId == currentGroupId } + } + } + val currentGroupBookCount by remember { derivedStateOf { uiState.currentGroupBookCount } } + + val clearSelection = { + viewModel.clearSelection() + } + val exitEditMode = { + viewModel.exitEditMode() + } + val toggleEditMode = { + viewModel.toggleEditMode() + } + val toggleBookSelection: (String) -> Unit = { bookUrl -> + viewModel.toggleBookSelection(bookUrl) + } + + LaunchedEffect(pagerState.currentPage, isInFolderRoot) { + clearSelection() + } + + BackHandler(enabled = isEditMode) { + if (selectedBookUrls.isNotEmpty()) { + clearSelection() + } else { + exitEditMode() + } + } + + val currentGroupName by remember { derivedStateOf { uiState.currentGroupName } } + + PredictiveBackHandler(enabled = bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) { progress -> + try { + progress.collect { backEvent -> + transitionState.seekTo(backEvent.progress, targetState = true) + } + viewModel.setInFolderRoot(true) + transitionState.animateTo(true) + } catch (e: CancellationException) { + transitionState.animateTo(false) + } + } + + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val bookshelfFolderLayoutMode by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfFolderLayoutModeLandscapeState.value + else BookshelfConfig.bookshelfFolderLayoutModePortraitState.value + } + } + val bookshelfFolderLayoutGrid by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfFolderLayoutGridLandscapeState.value + else BookshelfConfig.bookshelfFolderLayoutGridPortraitState.value + } + } + val bookshelfFolderLayoutList by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfFolderLayoutListLandscapeState.value + else BookshelfConfig.bookshelfFolderLayoutListPortraitState.value + } + } + val currentMenuGroupId by remember { + derivedStateOf { if (uiState.isSearch) uiState.selectedGroupId else currentTabGroupId } + } + val editStickySummary by remember { + derivedStateOf { + if (uiState.isEditMode) { + BookshelfEditStickySummary( + selectedCount = uiState.selectedBookUrls.size, + currentGroupTotalCount = currentGroupBookCount, + groupName = currentGroupName, + showGroupName = uiState.bookGroupStyle != 0 + ) + } else { + null + } + } + } + + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + var showTopBarMenu by remember { mutableStateOf(false) } + val onSearchClick = { + if (BookshelfConfig.bookshelfSearchActionDirectToSearchState.value) { + onNavigateToSearch(uiState.searchKey.trim()) + } else { + val active = !uiState.isSearch + viewModel.setSearchMode(active) + if (!active && uiState.selectedGroupId != currentTabGroupId) { + viewModel.changeGroup(currentTabGroupId) + } + } + } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .padding( + bottom = 72.dp + WindowInsets.navigationBars + .asPaddingValues() + .calculateBottomPadding() + ) + ) + }, + topBar = { + BookshelfTopBar( + uiState = uiState, + scrollBehavior = scrollBehavior, + onSearchClick = onSearchClick, + onSearchQueryChange = { viewModel.setSearchKey(it) }, + onSearchSubmit = { rawQuery -> + rawQuery.trim() + .takeIf { it.isNotEmpty() } + ?.let(onNavigateToSearch) + }, + onClearSearch = { viewModel.setSearchKey("") }, + actions = { + AnimatedVisibility(visible = isEditMode) { + TopBarActionButton( + onClick = { viewModel.selectAllVisible() }, + imageVector = Icons.Default.SelectAll, + contentDescription = stringResource(R.string.select_all) + ) + } + AnimatedVisibility(visible = isEditMode) { + TopBarActionButton( + onClick = { viewModel.invertVisibleSelection() }, + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.revert_selection) + ) + } + AnimatedVisibility(visible = isEditMode) { + TopBarActionButton( + onClick = { + if (selectedBookUrls.isNotEmpty()) { + viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog) + } + }, + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.action_download) + ) + } + AnimatedVisibility(visible = isEditMode) { + TopBarActionButton( + onClick = { + if (selectedBookUrls.isNotEmpty()) { + viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet) + } + }, + imageVector = Icons.Default.Bookmarks, + contentDescription = stringResource(R.string.move_to_group) + ) + } + + if (!isEditMode) { + Box { + TopBarActionButton( + onClick = { showTopBarMenu = true }, + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_menu) + ) + RoundDropdownMenu( + expanded = showTopBarMenu, + onDismissRequest = { showTopBarMenu = false } + ) { 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 = { + viewModel.showOverlay(BookshelfOverlay.ConfigSheet) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.GridView, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.group_manage), + onClick = { + viewModel.showOverlay(BookshelfOverlay.GroupManageSheet) + dismiss() + }, + leadingIcon = { Icon(Icons.Outlined.ViewCarousel, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.add_url), + onClick = { + viewModel.showOverlay(BookshelfOverlay.AddUrlDialog) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.Link, null) } + ) + RoundDropdownMenuItem( + text = "选择模式", + onClick = { + toggleEditMode() + dismiss() + }, + leadingIcon = { Icon(Icons.Default.Edit, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.bookshelf_management), + onClick = { + val groupId = + uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId + ?: -1L + onNavigateToCache(groupId) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.Bookmarks, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.export_bookshelf), + onClick = { + viewModel.showOverlay(BookshelfOverlay.ExportSheet) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.UploadFile, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.import_bookshelf), + onClick = { + viewModel.showOverlay(BookshelfOverlay.ImportSheet) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.CloudDownload, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.log), + onClick = { + viewModel.showOverlay(BookshelfOverlay.LogSheet) + dismiss() + }, + leadingIcon = { Icon(Icons.Default.History, null) } + ) + } + } + } + }, + bottomContent = { + if (bookGroupStyle == 0 && uiState.groups.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .adaptiveHorizontalPaddingTab(), + verticalAlignment = Alignment.CenterVertically + ) { + val selectedTabIndex = + pagerState.currentPage.coerceIn(0, uiState.groups.size - 1) + val tabTitles = remember(uiState.groups) { + uiState.groups.map { it.groupName } + } + + AppTabRow( + tabTitles = tabTitles, + selectedTabIndex = selectedTabIndex, + onTabSelected = { index -> + scope.launch { pagerState.animateScrollToPage(index) } + }, + modifier = Modifier.weight(1f) + ) + + val showExpandButton by BookshelfConfig.shouldShowExpandButtonState + if (showExpandButton) { + Box(modifier = Modifier) { + SmallToggleButton( + checked = showGroupMenu, + onCheckedChange = { + if (it) { + viewModel.showOverlay(BookshelfOverlay.GroupMenu) + } else { + viewModel.dismissOverlay() + } + }, + style = ToggleStyle.Outlined, + icon = Icons.AutoMirrored.Filled.FormatListBulleted, + contentDescription = stringResource(R.string.group_manage) + ) + RoundDropdownMenu( + expanded = showGroupMenu, + onDismissRequest = { viewModel.dismissOverlay() } + ) { dismiss -> + uiState.groups.forEachIndexed { index, group -> + RoundDropdownMenuItem( + text = group.groupName, + onClick = { + if (uiState.isSearch) { + viewModel.changeGroup(group.groupId) + } + scope.launch { + pagerState.animateScrollToPage( + index + ) + } + dismiss() + }, + trailingIcon = { + val isSelected = if (uiState.isSearch) { + uiState.selectedGroupId == group.groupId + } else { + selectedTabIndex == index + } + if (isSelected) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + + if (uiState.isSearch) { + val allGroup = uiState.allGroups.firstOrNull { + it.groupId == BookGroup.IdAll + } + val hiddenGroups = uiState.allGroups.filter { + !it.show && it.groupId != BookGroup.IdAll + } + + if (allGroup != null || hiddenGroups.isNotEmpty()) { + PillHeaderDivider( + title = "${stringResource(R.string.all)} / ${ + stringResource( + R.string.hide + ) + }" + ) + + allGroup?.let { group -> + RoundDropdownMenuItem( + text = group.groupName, + onClick = { + viewModel.changeGroup(group.groupId) + dismiss() + }, + trailingIcon = { + if (uiState.selectedGroupId == group.groupId) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + + hiddenGroups.forEach { group -> + RoundDropdownMenuItem( + text = group.groupName, + onClick = { + viewModel.changeGroup(group.groupId) + dismiss() + }, + trailingIcon = { + if (uiState.selectedGroupId == group.groupId) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + } + } + } + } + } + } + } + } + ) + } + ) { paddingValues -> + val currentGroup by remember { + derivedStateOf { + if (uiState.isSearch) { + uiState.allGroups.firstOrNull { it.groupId == currentGroupId } + } else { + uiState.groups.getOrNull(pagerState.settledPage) + } + } + } + val pullToRefreshEnabled by remember { + derivedStateOf { (currentGroup?.enableRefresh ?: true) && !isEditMode } + } + + Box(Modifier.fillMaxSize()) { + AppPullToRefresh( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refreshBooks(uiState.items) }, + enabled = pullToRefreshEnabled, + topPadding = paddingValues.calculateTopPadding() + ) { + folderTransition.AnimatedContent( + transitionSpec = { + val easing = FastOutSlowInEasing + val duration = 480 + if (targetState) { + (fadeIn(animationSpec = tween(duration, easing = easing)) + + scaleIn( + initialScale = 1.2f, + animationSpec = tween(duration, easing = easing) + )) + .togetherWith(fadeOut(animationSpec = tween(duration, easing = easing)) + + scaleOut( + targetScale = 0.8f, + animationSpec = tween(duration, easing = easing) + ) + ) + } else { + (fadeIn(animationSpec = tween(duration, easing = easing)) + + scaleIn( + initialScale = 0.8f, + animationSpec = tween(duration, easing = easing) + )) + .togetherWith(fadeOut(animationSpec = tween(duration, easing = easing)) + + scaleOut( + targetScale = 1.2f, + animationSpec = tween(duration, easing = easing) + ) + ) + } + } + ) { isRoot -> + if (uiState.groups.isEmpty() && !uiState.isSearch) { + if (!uiState.isInitialLoading) { + EmptyMessage( + modifier = Modifier + .fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ), + messageResId = R.string.bookshelf_empty + ) + } + } else if (bookGroupStyle == 2 && isRoot && !isUsingStandaloneSearchGroup) { + val folderColumns = + if (bookshelfFolderLayoutMode == 0) bookshelfFolderLayoutList else bookshelfFolderLayoutGrid + val isGridMode = bookshelfFolderLayoutMode != 0 + FastScrollLazyVerticalGrid( + columns = GridCells.Fixed(folderColumns.coerceAtLeast(1)), + modifier = Modifier + .fillMaxSize() + .then( + with(sharedTransitionScope) { + if (this != null) Modifier.skipToLookaheadSize() else Modifier + } + ), + contentPadding = adaptiveContentPaddingBookshelf( + top = paddingValues.calculateTopPadding(), + bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp, + horizontal = 4.dp + ), + verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), + horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), + showFastScroll = BookshelfConfig.showBookshelfFastScrollerState.value + ) { + itemsIndexed( + uiState.groups, + key = { _, it -> it.groupId }) { index, group -> + val countText = if (BookshelfConfig.showBookCountState.value) { + uiState.groupBookCounts[group.groupId]?.let { + stringResource(R.string.book_count, it) + } + } else { + null + } + if (bookshelfFolderLayoutMode == 0) { + BookGroupItemList( + group = group, + previewBooks = uiState.groupPreviews[group.groupId] + ?: emptyList(), + countText = countText, + isCompact = BookshelfConfig.bookshelfLayoutCompactState.value, + titleSmallFont = BookshelfConfig.bookshelfTitleSmallFontState.value, + titleCenter = BookshelfConfig.bookshelfTitleCenterState.value, + titleMaxLines = BookshelfConfig.bookshelfTitleMaxLinesState.value, + coverShadow = BookshelfConfig.bookshelfCoverShadowState.value, + onClick = { + scope.launch { pagerState.scrollToPage(index) } + viewModel.setInFolderRoot(false) + }, + onLongClick = { + viewModel.showOverlay(BookshelfOverlay.GroupEditSheet(group.groupId)) + }, + onBookClick = onBookClick + ) + } else { + BookGroupItemGrid( + group = group, + previewBooks = uiState.groupPreviews[group.groupId] + ?: emptyList(), + countText = countText, + gridStyle = BookshelfConfig.bookshelfGridLayoutState.value, + titleSmallFont = BookshelfConfig.bookshelfTitleSmallFontState.value, + titleCenter = BookshelfConfig.bookshelfTitleCenterState.value, + titleMaxLines = BookshelfConfig.bookshelfTitleMaxLinesState.value, + coverShadow = BookshelfConfig.bookshelfCoverShadowState.value, + onClick = { + scope.launch { pagerState.scrollToPage(index) } + viewModel.setInFolderRoot(false) + }, + onLongClick = { + viewModel.showOverlay(BookshelfOverlay.GroupEditSheet(group.groupId)) + } + ) + } + } + } + } else { + if (isUsingStandaloneSearchGroup) { + BookshelfPage( + paddingValues = paddingValues, + books = uiState.items, + uiState = uiState, + selectedBookUrls = selectedBookUrls, + canReorderBooks = false, + onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) }, + draggingBooks = null, + pendingSavedBooks = null, + onDragStarted = {}, + onMoveBook = { _, _, _ -> }, + onDragFinished = {}, + onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, + onBookClick = onBookClick, + onBookLongClick = onBookLongClick, + isCurrentPage = true, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .then( + with(sharedTransitionScope) { + if (this != null) Modifier.skipToLookaheadSize() else Modifier + } + ), + beyondViewportPageCount = 0, + key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it } + ) { pageIndex -> + val group = uiState.groups.getOrNull(pageIndex) + if (group != null) { + val isSelectedGroup = group.groupId == uiState.selectedGroupId + val books = uiState.allGroupBooks[group.groupId] + ?: if (isSelectedGroup) uiState.items + else persistentListOf() + val canReorderBooks = isEditMode && + !uiState.isSearch && + (group.bookSort.takeIf { it >= 0 } + ?: uiState.bookshelfSort) == 3 && + isSelectedGroup + BookshelfPage( + paddingValues = paddingValues, + books = books, + uiState = uiState, + selectedBookUrls = selectedBookUrls, + canReorderBooks = canReorderBooks, + onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) }, + draggingBooks = if (isSelectedGroup) { + uiState.draggingBooks + } else { + null + }, + pendingSavedBooks = if (isSelectedGroup) { + uiState.pendingSavedBooks + } else { + null + }, + onDragStarted = { + if (isSelectedGroup) viewModel.startDraggingBooks(it) + }, + onMoveBook = { from, to, currentBooks -> + if (isSelectedGroup) { + viewModel.moveDraggingBook(from, to, currentBooks) + } + }, + onDragFinished = { + if (isSelectedGroup) viewModel.finishDraggingBooks() + }, + onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, + onBookClick = onBookClick, + onBookLongClick = onBookLongClick, + isCurrentPage = isSelectedGroup, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + } + } + } + + 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 = stringResource( + R.string.bookshelf_selected_count, + summary.selectedCount + ), + style = LegadoTheme.typography.labelSmallEmphasized + ) + AppText( + text = " · ${ + stringResource( + R.string.bookshelf_total_count, + 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 = { + viewModel.showOverlay(BookshelfOverlay.GroupMenu) + } + ) + } + } + } + + + if (summary.showGroupName) { + RoundDropdownMenu( + expanded = showGroupMenu, + onDismissRequest = { viewModel.dismissOverlay() } + ) { 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) { + viewModel.setInFolderRoot(false) + } + dismiss() + }, + trailingIcon = { + if (currentMenuGroupId == group.groupId) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + } + } + } + } + + } + } + + BookshelfOverlays( + activeOverlay = activeOverlay, + uiState = uiState, + viewModel = viewModel, + selectedBookUrls = selectedBookUrls, + importLauncher = importLauncher, + exportLauncher = exportLauncher, + clearSelection = clearSelection + ) +} + +@Composable +private fun BookshelfTopBar( + uiState: BookshelfUiState, + scrollBehavior: GlassTopAppBarScrollBehavior, + onSearchClick: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onSearchSubmit: (String) -> Unit, + onClearSearch: () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + bottomContent: @Composable ColumnScope.() -> Unit = {} +) { + val searchContentDescription = stringResource(R.string.search) + GlassMediumFlexibleTopAppBar( + modifier = Modifier.fillMaxWidth(), + title = if (uiState.isLoading) { + stringResource(R.string.loading) + } else { + uiState.title.ifEmpty { stringResource(R.string.bookshelf) } + }, + useCharMode = uiState.isLoading, + subtitle = uiState.subtitle, + scrollBehavior = scrollBehavior, + actions = { + TopBarActionButton( + onClick = onSearchClick, + imageVector = AppIcons.Search, + contentDescription = searchContentDescription + ) + actions() + }, + bottomContent = { + AnimatedVisibility( + modifier = Modifier.adaptiveHorizontalPadding(), + visible = uiState.isSearch, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + SearchBar( + query = uiState.searchKey, + onQueryChange = onSearchQueryChange, + onSearch = onSearchSubmit, + placeholder = stringResource(R.string.input_search_key), + leadingIcon = { + AppIcon( + imageVector = AppIcons.Search, + contentDescription = null + ) + }, + trailingIcon = { + if (uiState.searchKey.isNotEmpty()) { + SmallPlainButton( + modifier = Modifier.padding(start = 12.dp), + onClick = onClearSearch, + icon = AppIcons.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } + ) + } + bottomContent() + } + ) +} + +@Composable +private fun BookshelfOverlays( + activeOverlay: BookshelfOverlay?, + uiState: BookshelfUiState, + viewModel: BookshelfViewModel, + selectedBookUrls: Set, + importLauncher: ManagedActivityResultLauncher, Uri?>, + exportLauncher: ManagedActivityResultLauncher, + clearSelection: () -> Unit +) { + BookshelfConfigSheet( + show = activeOverlay == BookshelfOverlay.ConfigSheet, + onDismissRequest = { viewModel.dismissOverlay() } + ) + + GroupManageSheet( + show = activeOverlay == BookshelfOverlay.GroupManageSheet, + onDismissRequest = { viewModel.dismissOverlay() } + ) + + val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle() + + if (activeOverlay is BookshelfOverlay.GroupEditSheet) { + val editGroup = groups.firstOrNull { it.groupId == activeOverlay.groupId } + if (editGroup != null) { + GroupEditSheet( + show = true, + group = editGroup, + onDismissRequest = { viewModel.dismissOverlay() } + ) + } + } + + GroupSelectSheet( + show = activeOverlay == BookshelfOverlay.GroupSelectSheet, + groups = groups.filter { it.groupId > 0 }, + currentGroupId = 0L, + onDismissRequest = { viewModel.dismissOverlay() }, + onConfirm = { groupId -> + viewModel.moveBooksToGroup(selectedBookUrls, groupId) + viewModel.dismissOverlay() + clearSelection() + } + ) + + SourceInputDialog( + show = activeOverlay == BookshelfOverlay.AddUrlDialog, + title = stringResource(R.string.add_book_url), + onDismissRequest = { viewModel.dismissOverlay() }, + onConfirm = { url -> + viewModel.addBookByUrl(url) + viewModel.dismissOverlay() + } + ) + + FilePickerSheet( + show = activeOverlay == BookshelfOverlay.ImportSheet, + onDismissRequest = { viewModel.dismissOverlay() }, + title = stringResource(R.string.import_bookshelf), + onSelectSysFile = { types -> + importLauncher.launch(types) + viewModel.dismissOverlay() + }, + onManualInput = { + viewModel.showOverlay(BookshelfOverlay.AddUrlDialog) + }, + allowExtensions = arrayOf("json", "txt") + ) + + FilePickerSheet( + show = activeOverlay == BookshelfOverlay.ExportSheet, + onDismissRequest = { viewModel.dismissOverlay() }, + title = stringResource(R.string.export_bookshelf), + onSelectSysDir = { + viewModel.dismissOverlay() + exportLauncher.launch("bookshelf.json") + }, + onUpload = { + viewModel.dismissOverlay() + viewModel.uploadBookshelf(uiState.items) + } + ) + + AppLogSheet( + show = activeOverlay == BookshelfOverlay.LogSheet, + onDismissRequest = { viewModel.dismissOverlay() } + ) + + AppAlertDialog( + show = activeOverlay == BookshelfOverlay.BatchDownloadConfirmDialog, + onDismissRequest = { viewModel.dismissOverlay() }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_cache_book), + confirmText = stringResource(android.R.string.ok), + onConfirm = { + viewModel.dismissOverlay() + viewModel.downloadBooks(selectedBookUrls) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { viewModel.dismissOverlay() } + ) + + if (uiState.isLoading) { + Dialog(onDismissRequest = {}) { + NormalCard( + cornerRadius = 12.dp, + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppCircularProgressIndicator() + uiState.loadingText?.let { + AppText( + text = it, + modifier = Modifier.padding(top = 16.dp), + style = LegadoTheme.typography.bodyMedium + ) + } + } + } + } + } +} + +private data class BookshelfEditStickySummary( + val selectedCount: Int, + val currentGroupTotalCount: Int, + val groupName: String?, + val showGroupName: Boolean, +) + +@Composable +fun BookshelfPage( + paddingValues: PaddingValues, + books: ImmutableList, + uiState: BookshelfUiState, + selectedBookUrls: ImmutableSet, + canReorderBooks: Boolean, + onToggleBookSelection: (BookUiItem) -> Unit, + draggingBooks: ImmutableList?, + pendingSavedBooks: ImmutableList?, + onDragStarted: (ImmutableList) -> Unit, + onMoveBook: (fromIndex: Int, toIndex: Int, currentBooks: ImmutableList) -> Unit, + onDragFinished: () -> Unit, + onGlobalSearch: () -> Unit, + onBookClick: (BookShelfItem) -> Unit, + onBookLongClick: (BookShelfItem, String?) -> Unit, + isCurrentPage: Boolean = true, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + if (books.isEmpty()) { + if (!isCurrentPage) return + if (uiState.isSearch) { + EmptyMessage( + modifier = Modifier + .fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ), + message = stringResource(R.string.bookshelf_empty_global_search), + buttonText = stringResource(R.string.global_search), + onButtonClick = onGlobalSearch + ) + } else if (!uiState.isInitialLoading) { + EmptyMessage( + modifier = Modifier + .fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ), + messageResId = R.string.bookshelf_empty + ) + } + return + } + + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val bookshelfLayoutMode by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscapeState.value + else BookshelfConfig.bookshelfLayoutModePortraitState.value + } + } + val bookshelfLayoutGrid by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscapeState.value + else BookshelfConfig.bookshelfLayoutGridPortraitState.value + } + } + val bookshelfLayoutList by remember(isLandscape) { + derivedStateOf { + if (isLandscape) BookshelfConfig.bookshelfLayoutListLandscapeState.value + else BookshelfConfig.bookshelfLayoutListPortraitState.value + } + } + val columns by remember { + derivedStateOf { + if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid + } + } + val isGridMode by remember { derivedStateOf { bookshelfLayoutMode != 0 } } + val bookItemGridStyle by BookshelfConfig.bookshelfGridLayoutState + val bookItemIsCompact by BookshelfConfig.bookshelfLayoutCompactState + val bookItemTitleSmallFont by BookshelfConfig.bookshelfTitleSmallFontState + val bookItemTitleCenter by BookshelfConfig.bookshelfTitleCenterState + val bookItemTitleMaxLines by BookshelfConfig.bookshelfTitleMaxLinesState + val bookItemCoverShadow by BookshelfConfig.bookshelfCoverShadowState + val showFastScroll by BookshelfConfig.showBookshelfFastScrollerState + val totalHorizontalPadding = + if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp + val gridContentHorizontalPadding = totalHorizontalPadding / 2 + val gridInnerHorizontalPadding = totalHorizontalPadding / 2 + val hapticFeedback = LocalHapticFeedback.current + val displayBooks = draggingBooks ?: pendingSavedBooks ?: books + val gridState = rememberLazyGridState() + val reorderableState = rememberReorderableLazyGridState(gridState) { from, to -> + if (canReorderBooks) { + onMoveBook(from.index, to.index, displayBooks) + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + } + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + onDragFinished() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .then( + with(sharedTransitionScope) { + if (this != null) Modifier.skipToLookaheadSize() else Modifier + } + ) + ) { + FastScrollLazyVerticalGrid( + columns = GridCells.Fixed(columns.coerceAtLeast(1)), + state = gridState, + modifier = Modifier + .fillMaxSize() + .semantics(mergeDescendants = true) { contentDescription = "bookshelf_list" } + .then( + with(sharedTransitionScope) { + if (this != null) Modifier.skipToLookaheadSize() else Modifier + } + ), + contentPadding = adaptiveContentPaddingBookshelf( + top = paddingValues.calculateTopPadding(), + bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp, + horizontal = 8.dp + ), + verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), + horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp), + showFastScroll = showFastScroll + ) { + items(displayBooks, key = { it.book.bookUrl }) { bookUi -> + val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl) + val sharedCoverKey = if (isCurrentPage) { + bookCoverSharedElementKey( + bookUi.book.bookUrl, + "bookshelf:${uiState.selectedGroupId}" + ) + } else { + null + } + ReorderableItem( + state = reorderableState, + key = bookUi.book.bookUrl, + enabled = canReorderBooks + ) { isDragging -> + BookItem( + bookUi = bookUi, + modifier = Modifier + .then( + if (canReorderBooks) { + Modifier.longPressDraggableHandle( + onDragStarted = { + onDragStarted(displayBooks) + hapticFeedback.performHapticFeedback( + HapticFeedbackType.GestureThresholdActivate + ) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.GestureEnd + ) + } + ) + } else { + Modifier + } + ) + .alpha(if (isDragging) 0.5f else 1f), + layoutMode = bookshelfLayoutMode, + isSelected = isSelected, + gridStyle = bookItemGridStyle, + isCompact = bookItemIsCompact, + isUpdating = uiState.updatingBooks.contains(bookUi.book.bookUrl), + titleSmallFont = bookItemTitleSmallFont, + titleCenter = bookItemTitleCenter, + titleMaxLines = bookItemTitleMaxLines, + coverShadow = bookItemCoverShadow, + isSearchMode = uiState.isSearch, + searchKey = uiState.searchKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, + onClick = { + if (uiState.isEditMode) { + onToggleBookSelection(bookUi) + } else { + onBookClick(bookUi.book) + } + }, + onLongClick = if (canReorderBooks) { + null + } else { + { + if (uiState.isEditMode) { + onToggleBookSelection(bookUi) + } else { + onBookLongClick(bookUi.book, sharedCoverKey) + } + } + } + ) + } + } + } + } +} From 654f3095af3cd4b9c8b91bcbbe6abc60981319b8 Mon Sep 17 00:00:00 2001 From: shilimesfriend <1700635609@qq.com> Date: Fri, 26 Jun 2026 23:13:48 +0800 Subject: [PATCH 4/4] refactor(bookshelf): use graphicsLayer lambda for drag alpha per review --- .../java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 0db0024c6..84bd7a799 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -84,7 +84,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.ClipEntry @@ -1376,7 +1376,9 @@ fun BookshelfPage( Modifier } ) - .alpha(if (isDragging) 0.5f else 1f), + .graphicsLayer { + alpha = if (isDragging) 0.5f else 1f + }, layoutMode = bookshelfLayoutMode, isSelected = isSelected, gridStyle = bookItemGridStyle,