From b31f56b953a3e64b3b78e95c4a1778ce6fc21007 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Sat, 21 Feb 2026 00:46:23 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=A1=B5=E9=9D=A2=E6=80=A7=E8=83=BD=E5=92=8C?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/legado/app/data/dao/BookDao.kt | 3 + .../java/io/legado/app/help/book/BookHelp.kt | 2 +- .../java/io/legado/app/model/CacheBook.kt | 8 +- .../legado/app/ui/book/toc/DownloadState.kt | 3 +- .../io/legado/app/ui/book/toc/TocActivity.kt | 14 +- .../io/legado/app/ui/book/toc/TocScreen.kt | 389 +++++----- .../io/legado/app/ui/book/toc/TocViewModel.kt | 696 +++++++++--------- 7 files changed, 576 insertions(+), 539 deletions(-) diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index 088722b21..1b3a20ff3 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -109,6 +109,9 @@ interface BookDao { @Query("SELECT * FROM books WHERE bookUrl = :bookUrl") fun getBook(bookUrl: String): Book? + @Query("SELECT * FROM books WHERE bookUrl = :bookUrl") + fun flowGetBook(bookUrl: String): Flow + @Query("SELECT * FROM books WHERE name = :name and author = :author") fun getBook(name: String, author: String): Book? diff --git a/app/src/main/java/io/legado/app/help/book/BookHelp.kt b/app/src/main/java/io/legado/app/help/book/BookHelp.kt index 1d3d9d495..92d2ed0a5 100644 --- a/app/src/main/java/io/legado/app/help/book/BookHelp.kt +++ b/app/src/main/java/io/legado/app/help/book/BookHelp.kt @@ -189,7 +189,7 @@ object BookHelp { if (book.isOnLineTxt && AppConfig.tocCountWords) { val wordCount = StringUtils.wordCountFormat(content.length) bookChapter.wordCount = wordCount - appDb.bookChapterDao.upWordCount(bookChapter.bookUrl, bookChapter.url, wordCount) + appDb.bookChapterDao.update(bookChapter) } } diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 7f0692639..ade4f55f9 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -52,14 +52,14 @@ object CacheBook { val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow() private val _downloadingIndicesFlow = - MutableSharedFlow>>(extraBufferCapacity = 64) + MutableStateFlow>>("" to emptySet()) - val downloadingIndicesFlow = _downloadingIndicesFlow.asSharedFlow() + val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow() private val _downloadErrorFlow = - MutableSharedFlow>>(extraBufferCapacity = 64) + MutableStateFlow>>("" to emptySet()) - val downloadErrorFlow = _downloadErrorFlow.asSharedFlow() + val downloadErrorFlow = _downloadErrorFlow.asStateFlow() private fun updateSummary() { _downloadSummaryFlow.value = downloadSummary diff --git a/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt b/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt index 0c2dbeb4b..1e3a3b256 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt @@ -4,5 +4,6 @@ enum class DownloadState { NONE, DOWNLOADING, SUCCESS, - ERROR + ERROR, + LOCAL } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt index 1943441f2..1489dcadf 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt @@ -13,17 +13,12 @@ import io.legado.app.ui.replace.ReplaceRuleActivity */ class TocActivity : BaseComposeActivity() { - private val bookUrl: String? by lazy { - intent.getStringExtra("bookUrl") - } - @Composable override fun Content() { val context = LocalContext.current TocScreen( onBackClick = { finish() }, - bookUrl = bookUrl, onChapterClick = { index -> val data = Intent().apply { putExtra("index", index) @@ -35,7 +30,14 @@ class TocActivity : BaseComposeActivity() { val intent = ReplaceRuleActivity.startIntent(context, editRoute) context.startActivity(intent) }, - onBookmarkClick = { _, _ -> /* 书签点击 */ }, + onBookmarkClick = { index, pos -> + val data = Intent().apply { + putExtra("index", index) + putExtra("chapterPos", pos) + } + setResult(RESULT_OK, data) + finish() + } ) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt index 3e9379233..c025b9ce6 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -28,7 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -48,17 +49,15 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.VerticalAlignTop +import androidx.compose.material.icons.outlined.DownloadForOffline import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButtonMenu import androidx.compose.material3.FloatingActionButtonMenuItem import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow @@ -82,6 +81,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.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -90,15 +90,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.Bookmark import io.legado.app.help.book.isLocal import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.replace.ReplaceEditRoute import io.legado.app.ui.widget.CollapsibleHeader import io.legado.app.ui.widget.components.ActionItem +import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.SelectionBottomBar import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet import io.legado.app.ui.widget.components.bookmark.BookmarkItem @@ -106,7 +107,7 @@ import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.divider.PillHeaderDivider import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn -import io.legado.app.ui.widget.components.lazylist.Scroller +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.topbar.DynamicTopAppBar import kotlinx.coroutines.launch @@ -119,12 +120,12 @@ import java.util.Locale @Composable fun TocScreen( viewModel: TocViewModel = koinViewModel(), - bookUrl: String?, onBackClick: () -> Unit, onChapterClick: (Int) -> Unit, onOpenReplaceRule: (ReplaceEditRoute?) -> Unit, onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, ) { + val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val book by viewModel.bookState.collectAsStateWithLifecycle() @@ -132,11 +133,17 @@ fun TocScreen( val pagerState = rememberPagerState { 2 } val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val offset by remember { + derivedStateOf { + listState.layoutInfo.viewportEndOffset / 4 + } + } val isSelectionMode = state.selectedIds.isNotEmpty() - val hasVolumes = remember(state.items) { state.items.any { it.chapter.isVolume } } + val hasVolumes = remember(state.items) { state.items.any { it.isVolume } } var showVolumeMenu by remember { mutableStateOf(false) } var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } @@ -156,60 +163,32 @@ fun TocScreen( } val isOnTocPage = pagerState.currentPage == 0 - var isFabVisible by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(listState, isOnTocPage, isSelectionMode) { - - if (!isOnTocPage || isSelectionMode) { - isFabVisible = false - return@LaunchedEffect - } - - var previousIndex = listState.firstVisibleItemIndex - var previousOffset = listState.firstVisibleItemScrollOffset - - snapshotFlow { - listState.firstVisibleItemIndex to - listState.firstVisibleItemScrollOffset - } - .collect { (index, offset) -> - - val scrollingDown = - index > previousIndex || - (index == previousIndex && offset > previousOffset) - - val scrollingUp = - index < previousIndex || - (index == previousIndex && offset < previousOffset) - - when { - scrollingDown -> isFabVisible = false - scrollingUp -> isFabVisible = true + val fabItems = remember(state.items) { + listOf( + FabAction(Icons.Default.LocationOn, "定位至当前阅读") { + scope.launch { + val target = state.items.indexOfFirst { it.isDur } + if (target != -1) { + listState.animateScrollToItem( + index = target, + scrollOffset = -offset + ) + } } - - previousIndex = index - previousOffset = offset + }, + FabAction(Icons.Default.VerticalAlignTop, "移至顶部") { + scope.launch { listState.animateScrollToItem(0) } + }, + FabAction(Icons.Default.VerticalAlignBottom, "移至底部") { + scope.launch { listState.animateScrollToItem(state.items.size) } + }, + FabAction(Icons.Default.DownloadForOffline, "下载全部") { + viewModel.downloadAll() } + ) } - val fabItems = listOf( - Triple(Icons.Default.LocationOn, "定位至当前阅读") { - scope.launch { - val target = state.items.indexOfFirst { it.chapter.index == book?.durChapterIndex } - if (target != -1) listState.animateScrollToItem(target) - } - }, - Triple(Icons.Default.VerticalAlignTop, "移至顶部") { - scope.launch { listState.animateScrollToItem(0) } - }, - Triple(Icons.Default.VerticalAlignBottom, "移至底部") { - scope.launch { listState.animateScrollToItem(state.items.size) } - }, - Triple(Icons.Default.DownloadForOffline, "下载全部") { - viewModel.downloadAll() - } - ) - val selectionSecondaryActions = remember(state.selectedIds) { listOf( ActionItem( @@ -248,14 +227,50 @@ fun TocScreen( } } - LaunchedEffect(isFabVisible) { - if (!isFabVisible && fabMenuExpanded) { - fabMenuExpanded = false + var hasAutoScrolled by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(state.items) { + if (!hasAutoScrolled && state.items.isNotEmpty()) { + val targetIndex = state.items.indexOfFirst { it.isDur } + if (targetIndex != -1) { + listState.scrollToItem( + index = targetIndex, + scrollOffset = -offset + ) + hasAutoScrolled = true + } } } - LaunchedEffect(bookUrl) { - bookUrl?.let { viewModel.initBook(it) } + var isFabVisible by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(listState) { + var previousIndex = listState.firstVisibleItemIndex + var previousOffset = listState.firstVisibleItemScrollOffset + + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + val scrollingDown = + index > previousIndex || (index == previousIndex && offset > previousOffset) + val scrollingUp = + index < previousIndex || (index == previousIndex && offset < previousOffset) + + when { + scrollingDown -> isFabVisible = false + scrollingUp -> isFabVisible = true + } + + previousIndex = index + previousOffset = offset + } + } + + val shouldShowFab = isOnTocPage && !isSelectionMode && isFabVisible + + LaunchedEffect(shouldShowFab) { + if (!shouldShowFab) { + fabMenuExpanded = false + } } BackHandler(enabled = isSelectionMode) { @@ -425,51 +440,37 @@ fun TocScreen( icon = Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = "卷管理" ) - DropdownMenu( + RoundDropdownMenu( expanded = showVolumeMenu, onDismissRequest = { showVolumeMenu = false } ) { - DropdownMenuItem( - text = { Text("展开所有卷") }, + RoundDropdownMenuItem( + text = "展开所有卷", onClick = { viewModel.expandAllVolumes(); showVolumeMenu = false } ) - DropdownMenuItem( - text = { Text("收起所有卷") }, + RoundDropdownMenuItem( + text = "收起所有卷", onClick = { viewModel.collapseAllVolumes(); showVolumeMenu = false } ) - HorizontalDivider() - val volumeItems = state.items.filter { it.chapter.isVolume } + + val volumeItems = + remember(state.items) { state.items.filter { it.isVolume } } if (volumeItems.isNotEmpty()) { - Text( - "快速跳转", - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 8.dp - ), - color = MaterialTheme.colorScheme.primary - ) + PillHeaderDivider(title = "快速跳转") volumeItems.forEach { uiItem -> - DropdownMenuItem( - text = { - Text( - uiItem.displayTitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, + RoundDropdownMenuItem( + text = uiItem.title, onClick = { scope.launch { val targetIndex = - state.items.indexOfFirst { it.chapter.index == uiItem.chapter.index } - + state.items.indexOf(uiItem) if (targetIndex != -1) { listState.animateScrollToItem( - targetIndex + index = targetIndex ) } } @@ -494,7 +495,7 @@ fun TocScreen( ToggleFloatingActionButton( modifier = Modifier .animateFloatingActionButton( - visible = isFabVisible, + visible = shouldShowFab, alignment = Alignment.BottomEnd, ) .focusRequester(focusRequester), @@ -562,8 +563,8 @@ fun TocScreen( 1 -> BookmarkListContent( viewModel = viewModel, - onBookmarkClick = onBookmarkClick, - onBookmarkLongClick = { bookmark -> + onBookmarkLongClick = onBookmarkClick, + onBookmarkClick = { bookmark -> editingBookmark = bookmark }, contentPadding = PaddingValues(bottom = if (isSelectionMode) 80.dp else 0.dp) @@ -599,7 +600,6 @@ fun ChapterListContent( contentPadding: PaddingValues ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val book by viewModel.bookState.collectAsStateWithLifecycle() val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle() FastScrollLazyColumn( @@ -607,124 +607,78 @@ fun ChapterListContent( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding ) { - state.items.forEach { uiItem -> - val chapter = uiItem.chapter - if (chapter.isVolume) { - stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${chapter.index}") { + state.items.forEach { uiItem -> + + if (uiItem.isVolume) { + + stickyHeader(key = "volume-${uiItem.id}") { CollapsibleHeader( modifier = Modifier.animateItem(), - title = uiItem.displayTitle, - isCollapsed = collapsedVolumes.contains(chapter.title), - onToggle = { viewModel.toggleVolume(chapter.title) } + title = uiItem.title, + isCollapsed = collapsedVolumes.contains(uiItem.id), + onToggle = { viewModel.toggleVolume(uiItem.id) } ) } + } else { - item(key = chapter.index) { - - val isSelected = state.selectedIds.contains(chapter.index) - - val downloadState = when { - chapter.index in state.downloadingIndices -> DownloadState.DOWNLOADING - chapter.index in state.errorIndices -> DownloadState.ERROR - chapter.getFileName() in state.cachedFiles -> DownloadState.SUCCESS - else -> DownloadState.NONE - } + item(key = uiItem.id) { ChapterItem( modifier = Modifier .animateItem() .fillMaxWidth(), - title = uiItem.displayTitle, - chapter = chapter, - isDur = book?.durChapterIndex == chapter.index, - isSelected = isSelected, - downloadState = downloadState, + item = uiItem, showWordCount = viewModel.showWordCount, onClick = { if (state.selectedIds.isNotEmpty()) - viewModel.toggleSelection(chapter.index) + viewModel.toggleSelection(uiItem.id) else - onChapterClick(chapter.index) + onChapterClick(uiItem.id) }, onLongClick = { - viewModel.toggleSelection(chapter.index) + viewModel.toggleSelection(uiItem.id) + }, + onDownloadClick = { + viewModel.downloadChapter(uiItem.id) } ) - - /*SwipeActionContainer( - modifier = Modifier - .animateItem() - .fillMaxWidth(), - startAction = SwipeAction( - icon = Icons.Default.Download, - background = MaterialTheme.colorScheme.secondary, - hapticFeedback = true, - onSwipe = { - viewModel.downloadChapter(chapter.index) - } - ) - ) { - ChapterItem( - modifier = Modifier - .animateItem() - .fillMaxWidth(), - title = uiItem.displayTitle, - chapter = chapter, - isDur = book?.durChapterIndex == chapter.index, - isSelected = isSelected, - downloadState = downloadState, - showWordCount = viewModel.showWordCount, - onClick = { - if (state.selectedIds.isNotEmpty()) - viewModel.toggleSelection(chapter.index) - else - onChapterClick(chapter.index) - }, - onLongClick = { - viewModel.toggleSelection(chapter.index) - } - ) - }*/ } } } } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChapterItem( modifier: Modifier = Modifier, - title: String, - chapter: BookChapter, - isDur: Boolean, - isSelected: Boolean, - downloadState: DownloadState, + item: TocItemUi, showWordCount: Boolean, onClick: () -> Unit, - onLongClick: () -> Unit + onLongClick: () -> Unit, + onDownloadClick: () -> Unit ) { val backgroundColor by animateColorAsState( targetValue = when { - isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - isDur -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) + item.isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + item.isDur -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) else -> Color.Transparent }, label = "BgColor" ) val textColor by animateColorAsState( targetValue = when { - isSelected -> MaterialTheme.colorScheme.onSurface - isDur -> MaterialTheme.colorScheme.primary + item.isSelected -> MaterialTheme.colorScheme.onSurface + item.isDur -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurface }, label = "BgColor" ) val detailColor by animateColorAsState( targetValue = when { - isSelected -> MaterialTheme.colorScheme.onSurfaceVariant - isDur -> MaterialTheme.colorScheme.primary + item.isSelected -> MaterialTheme.colorScheme.onSurfaceVariant + item.isDur -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant }, label = "BgColor" ) @@ -740,12 +694,12 @@ fun ChapterItem( ) { Row( modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - if (chapter.isVip && !chapter.isPay) { + if (item.isVip && !item.isPay) { Icon( imageVector = Icons.Default.Lock, contentDescription = null, @@ -757,7 +711,7 @@ fun ChapterItem( } Text( - text = title, + text = item.title, style = MaterialTheme.typography.bodyMediumEmphasized.copy(fontWeight = FontWeight.Medium), color = textColor, maxLines = 1, @@ -765,28 +719,40 @@ fun ChapterItem( ) } - if (!chapter.tag.isNullOrEmpty()) { + if (!item.tag.isNullOrEmpty()) { Text( - text = chapter.tag!!, + text = item.tag, style = MaterialTheme.typography.labelSmallEmphasized, color = detailColor.copy(alpha = 0.8f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } + } - if (showWordCount && !chapter.wordCount.isNullOrEmpty()) { - Text( - text = "字数:${chapter.wordCount}", - style = MaterialTheme.typography.labelSmallEmphasized, - color = detailColor.copy(alpha = 0.6f), - maxLines = 1, - overflow = TextOverflow.Ellipsis + if (item.downloadState != DownloadState.LOCAL) { + Box( + modifier = Modifier + .padding(start = 8.dp) + .wrapContentSize() + .clip(MaterialTheme.shapes.medium) + .combinedClickable( + onClick = { + if (item.downloadState == DownloadState.NONE) { + onDownloadClick() + } + } + ), + contentAlignment = Alignment.Center + ) { + StatusIcon( + isDur = item.isDur, + downloadState = item.downloadState, + wordCount = item.wordCount, + showWordCount = showWordCount ) } } - - StatusIcon(isDur = isDur, downloadState = downloadState) } } } @@ -795,11 +761,11 @@ fun ChapterItem( @Composable fun BookmarkListContent( viewModel: TocViewModel, - onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, - onBookmarkLongClick: (Bookmark) -> Unit, + onBookmarkLongClick: (chapterIndex: Int, chapterPos: Int) -> Unit, + onBookmarkClick: (Bookmark) -> Unit, contentPadding: PaddingValues ) { - val bookmarks by viewModel.bookmarks.collectAsStateWithLifecycle() + val bookmarks by viewModel.bookmarkUiList.collectAsStateWithLifecycle() val book by viewModel.bookState.collectAsStateWithLifecycle() val listState = rememberLazyListState() @@ -820,30 +786,31 @@ fun BookmarkListContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text( - text = "暂无书签", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge + EmptyMessageView( + message = "暂无书签" ) } } else { - LazyColumn( + FastScrollLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding ) { items( items = bookmarks, - key = { it.time } + key = { it.id } ) { bookmark -> BookmarkItem( - bookmark = bookmark, + bookmark = bookmark.raw, + modifier = Modifier + .animateItem() + .fillMaxWidth(), isDur = book?.durChapterIndex == bookmark.chapterIndex, onClick = { - onBookmarkClick(bookmark.chapterIndex, bookmark.chapterPos) + onBookmarkClick(bookmark.raw) }, onLongClick = { - onBookmarkLongClick(bookmark) + onBookmarkLongClick(bookmark.chapterIndex, bookmark.chapterPos) } ) } @@ -854,12 +821,16 @@ fun BookmarkListContent( @Composable private fun StatusIcon( isDur: Boolean, - downloadState: DownloadState + downloadState: DownloadState, + wordCount: String?, + showWordCount: Boolean ) { + val targetState = when { isDur -> "DUR" downloadState == DownloadState.DOWNLOADING -> "LOADING" - downloadState == DownloadState.SUCCESS -> "SUCCESS" + downloadState == DownloadState.SUCCESS && showWordCount && !wordCount.isNullOrEmpty() -> "SUCCESS_WORD_COUNT" + downloadState == DownloadState.SUCCESS -> "SUCCESS_ICON" downloadState == DownloadState.ERROR -> "ERROR" else -> "NONE" } @@ -892,7 +863,28 @@ private fun StatusIcon( ) } - "SUCCESS" -> { + "SUCCESS_WORD_COUNT" -> { + Surface( + shape = MaterialTheme.shapes.medium, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant + ), + color = Color.Transparent + ) { + if (wordCount != null) { + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + text = wordCount, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + "SUCCESS_ICON" -> { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, @@ -911,7 +903,12 @@ private fun StatusIcon( } else -> { - + Icon( + imageVector = Icons.Outlined.DownloadForOffline, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index fd5671e93..7cd8efa71 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -2,7 +2,10 @@ package io.legado.app.ui.book.toc import android.app.Application import android.net.Uri +import androidx.compose.runtime.Immutable import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseRuleViewModel import io.legado.app.data.appDb @@ -11,6 +14,7 @@ import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.Bookmark import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.book.isLocal import io.legado.app.help.bookmark.BookmarkExporter import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook @@ -25,124 +29,327 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -data class TocUiItem( - val chapter: BookChapter, - val displayTitle: String, - override val id: Int = chapter.index +@Immutable +data class TocItemUi( + override val id: Int, + val title: String, + val tag: String?, + val isVolume: Boolean, + val isVip: Boolean, + val isPay: Boolean, + val isDur: Boolean, + val isSelected: Boolean, + val downloadState: DownloadState, + val wordCount: String? ) : SelectableItem +@Immutable +data class TocBookmarkItemUi( + val id: Long, + val chapterIndex: Int, + val chapterPos: Int, + val content: String, + val chapterName: String, + val isDur: Boolean, + val raw: Bookmark +) + data class TocActionState( - override val items: List = emptyList(), + override val items: List = emptyList(), override val selectedIds: Set = emptySet(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isUploading: Boolean = false, - val downloadingIndices: Set = emptySet(), - val errorIndices: Set = emptySet(), - val cachedFiles: Set = emptySet(), val downloadSummary: String = "" -) : RuleActionState +) : RuleActionState + +data class TocDomainItem( + val chapter: BookChapter, + val displayTitle: String, + val downloadState: DownloadState +) + +private data class DownloadContext( + val downloadingPair: Pair>, + val errorPair: Pair>, + val cachedFiles: Set +) + +private data class TocUiConfig( + val collapsedVolumes: Set, + val useReplace: Boolean, + val showWordCount: Boolean, + val isReverse: Boolean +) + +data class FabAction(val icon: ImageVector, val label: String, val action: () -> Unit) @OptIn(ExperimentalCoroutinesApi::class) -class TocViewModel(application: Application) : - BaseRuleViewModel, Int, TocActionState>( - application, - initialState = TocActionState() - ) { +class TocViewModel( + application: Application, + savedStateHandle: SavedStateHandle +) : BaseRuleViewModel( + application, + initialState = TocActionState() +) { - private val _book = MutableStateFlow(null) - val bookState = _book.asStateFlow() - val isSplitLongChapter: Boolean - get() = _book.value?.getSplitLongChapter() ?: false + private val bookUrlFlow = savedStateHandle.getStateFlow("bookUrl", null) + val bookState = bookUrlFlow + .filterNotNull() + .flatMapLatest { url -> + appDb.bookDao.flowGetBook(url) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val _collapsedVolumes = MutableStateFlow>(emptySet()) + val isSplitLongChapter: Boolean get() = bookState.value?.getSplitLongChapter() ?: false + + private val _collapsedVolumes = MutableStateFlow>(emptySet()) val collapsedVolumes = _collapsedVolumes.asStateFlow() - private val _cacheFileNames = MutableStateFlow>(emptySet()) - val cacheFileNames = _cacheFileNames.asStateFlow() + val downloadSummary: StateFlow = + CacheBook.downloadSummaryFlow + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + "" + ) - private val _isReverse = MutableStateFlow(false) - - private val _downloadingIndices = MutableStateFlow>(emptySet()) - private val _downloadSummary = MutableStateFlow("") - - private val _errorIndices = MutableStateFlow>(emptySet()) - val errorIndices = _errorIndices.asStateFlow() - - private val _bookmarks = MutableStateFlow>(emptyList()) - val bookmarks = _bookmarks.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - private val dbChapterListFlow = _book - .filterNotNull() + private val _cacheFileNames: StateFlow> = bookState.filterNotNull() .map { it.bookUrl } .distinctUntilChanged() .flatMapLatest { url -> - appDb.bookChapterDao.getChapterListFlow(url) + val initialFiles = withContext(Dispatchers.IO) { + BookHelp.getChapterFiles(bookState.value!!) + }.toSet() + + CacheBook.cacheSuccessFlow + .filter { it.bookUrl == url } + .map { it.getFileName() } + .scan(initialFiles) { accumulator, newFileName -> + accumulator + newFileName + } } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptySet() + ) - @OptIn(ExperimentalCoroutinesApi::class) - private val processedChaptersFlow: Flow>> = combine( - dbChapterListFlow, - _isReverse, - _book.filterNotNull(), - snapshotFlow { ReadConfig.tocUiUseReplace }, - snapshotFlow { ReadConfig.tocCountWords } - ) { originalList, isReverse, book, useReplace, showWordCount -> - - val processedList = if (isReverse) { - originalList.fold(mutableListOf>()) { acc, chapter -> - if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter)) - else acc.last().add(chapter) - acc - }.asReversed().flatMap { group -> - if (group.firstOrNull()?.isVolume == true) { - listOf(group.first()) + group.drop(1).asReversed() - } else group.asReversed() + val bookmarkUiList: StateFlow> = + combine( + bookState.filterNotNull(), + _searchKey + ) { book, query -> + book to query + } + .flatMapLatest { (book, query) -> + appDb.bookmarkDao + .flowByBook(book.name, book.author) + .map { list -> + list + .asSequence() + .filter { + query.isBlank() || + it.content.contains(query, ignoreCase = true) + } + .map { bookmark -> + TocBookmarkItemUi( + id = bookmark.time, + chapterIndex = bookmark.chapterIndex, + chapterPos = bookmark.chapterPos, + content = bookmark.content, + chapterName = bookmark.chapterName, + isDur = bookmark.chapterIndex == book.durChapterIndex, + raw = bookmark + ) + } + .toList() + } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val reverseFlow = + bookState.map { it?.getReverseToc() ?: false } + .distinctUntilChanged() + + private val downloadContextFlow = combine( + CacheBook.downloadingIndicesFlow, + CacheBook.downloadErrorFlow, + _cacheFileNames + ) { downloading, errors, cached -> + DownloadContext(downloading, errors, cached) + } + + private val uiConfigFlow = combine( + _collapsedVolumes, + snapshotFlow { ReadConfig.tocUiUseReplace }, + snapshotFlow { ReadConfig.tocCountWords }, + reverseFlow + ) { collapsed, useReplace, showWordCount, isReverse -> + TocUiConfig(collapsed, useReplace, showWordCount, isReverse) + } + + override val rawDataFlow: Flow> = combine( + bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged() + .flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) }, + downloadContextFlow, + uiConfigFlow + ) { originalChapters, downloadCtx, config -> + val book = bookState.value ?: return@combine emptyList() + + val processedChapters = if (config.isReverse) { + originalChapters.groupAndReverseVolumes() } else { - originalList + originalChapters } - val replaceRules = if (useReplace && book.getUseReplaceRule()) { + val replaceRules = if (config.useReplace && book.getUseReplaceRule()) { ContentProcessor.get(book.name, book.origin).getTitleReplaceRules() } else emptyList() - processedList.map { chapter -> - chapter to chapter.getDisplayTitle(replaceRules, true) + if (book.isLocal) { + return@combine processedChapters.map { chapter -> + TocDomainItem( + chapter = chapter, + displayTitle = chapter.getDisplayTitle(replaceRules, true), + downloadState = DownloadState.LOCAL + ) + } } - } - .flowOn(Dispatchers.IO) - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 1) - private val downloadStateTrigger = - combine( - _downloadingIndices, - _errorIndices, - _cacheFileNames, - _downloadSummary - ) { _, _, _, _ -> Unit } + val (downloadingPair, errorPair, cachedFiles) = downloadCtx + val downloadingIndices = + if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet() + val errorIndices = + if (errorPair.first == book.bookUrl) errorPair.second else emptySet() - override val rawDataFlow = - combine(processedChaptersFlow, _collapsedVolumes, downloadStateTrigger) { list, _, _ -> - list + processedChapters.map { chapter -> + val downloadState = when { + chapter.index in downloadingIndices -> DownloadState.DOWNLOADING + chapter.index in errorIndices -> DownloadState.ERROR + chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS + else -> DownloadState.NONE + } + + TocDomainItem( + chapter, + chapter.getDisplayTitle(replaceRules, true), + downloadState + ) } + }.flowOn(Dispatchers.Default) + val useReplace get() = ReadConfig.tocUiUseReplace val showWordCount get() = ReadConfig.tocCountWords + override fun filterData(data: List, key: String): List { + val collapsed = _collapsedVolumes.value + val isSearch = key.isNotBlank() + + return buildList { + var isCurrentVolumeCollapsed = false + for (item in data) { + if (item.chapter.isVolume) { + isCurrentVolumeCollapsed = collapsed.contains(item.chapter.index) + } else if (isCurrentVolumeCollapsed && !isSearch) { + continue + } + + if (!isSearch || item.displayTitle.contains(key, true) || item.chapter.isVolume) { + add(item) + } + } + } + } + + override fun composeUiState( + items: List, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): TocActionState { + + val durIndex = bookState.value?.durChapterIndex ?: -1 + + val updatedItems = items.map { uiItem -> + uiItem.copy( + isSelected = uiItem.id in selectedIds, + isDur = uiItem.id == durIndex + ) + } + + return TocActionState( + items = updatedItems, + selectedIds = selectedIds, + searchKey = _searchKey.value, + isSearch = isSearch, + isUploading = isUploading, + downloadSummary = downloadSummary.value + ) + } + + override fun TocDomainItem.toUiItem(): TocItemUi { + val wordCountText = if (showWordCount) { + chapter.wordCount + } else { + null + } + + return TocItemUi( + id = chapter.index, + title = displayTitle, + tag = chapter.tag, + isVolume = chapter.isVolume, + isVip = chapter.isVip, + isPay = chapter.isPay, + isDur = false, + isSelected = false, + downloadState = downloadState, + wordCount = wordCountText + ) + } + + override fun ruleItemToEntity(item: TocItemUi): TocDomainItem { + throw NotImplementedError("TOC 不需要向后反转实体") + } + + override suspend fun generateJson(entities: List) = "" + override fun parseImportRules(text: String): List = emptyList() + override fun hasChanged(newRule: TocDomainItem, oldRule: TocDomainItem) = false + override suspend fun findOldRule(newRule: TocDomainItem) = null + override fun saveImportedRules() {} + + fun reverseToc() = execute { + val currentBook = bookState.value ?: return@execute + val currentConfig = currentBook.readConfig ?: Book.ReadConfig() + val newConfig = currentConfig.copy(reverseToc = !currentConfig.reverseToc) + val newBook = currentBook.copy(readConfig = newConfig) + appDb.bookDao.update(newBook) + //bookState.value = newBook + } + fun toggleUseReplace() { ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace } @@ -151,208 +358,9 @@ class TocViewModel(application: Application) : ReadConfig.tocCountWords = !ReadConfig.tocCountWords } - override fun filterData( - data: List>, - key: String - ): List> { - val collapsed = _collapsedVolumes.value - val isSearch = key.isNotBlank() - - return buildList { - var isCurrentVolumeCollapsed = false - - for (item in data) { - val (chapter, displayTitle) = item - - if (chapter.isVolume) { - isCurrentVolumeCollapsed = collapsed.contains(chapter.title) - } else if (isCurrentVolumeCollapsed && !isSearch) { - continue - } - - if (!isSearch || displayTitle.contains( - key, - ignoreCase = true - ) || chapter.isVolume - ) { - add(item) - } - } - } - } - - override fun composeUiState( - items: List, - selectedIds: Set, - isSearch: Boolean, - isUploading: Boolean, - importState: BaseImportUiState> - ): TocActionState { - - return TocActionState( - items = items, - selectedIds = selectedIds, - searchKey = _searchKey.value, - isSearch = isSearch, - isUploading = isUploading, - downloadingIndices = _downloadingIndices.value, - errorIndices = _errorIndices.value, - cachedFiles = _cacheFileNames.value, - downloadSummary = _downloadSummary.value - ) - } - - override fun Pair.toUiItem() = TocUiItem(first, second) - - override fun ruleItemToEntity(item: TocUiItem) = item.chapter to item.displayTitle - - override suspend fun generateJson(entities: List>) = "" - override fun parseImportRules(text: String): List> = emptyList() - override fun hasChanged( - newRule: Pair, - oldRule: Pair - ) = false - - override suspend fun findOldRule(newRule: Pair) = null - override fun saveImportedRules() {} - - init { - - viewModelScope.launch { - combine( - _book.filterNotNull(), - snapshotFlow { uiState.value.searchKey } - ) { book, searchKey -> - if (searchKey.isBlank()) { - appDb.bookmarkDao.flowByBook(book.name, book.author) - } else { - appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey) - } - } - .flatMapLatest { it } - .catch { e -> - // AppLog.put("目录界面获取书签数据失败\n${e.localizedMessage}", e) - } - .flowOn(Dispatchers.IO) - .collect { list -> - _bookmarks.value = list - } - } - - viewModelScope.launch { - - launch { - CacheBook.downloadingIndicesFlow.collect { (url, set) -> - if (url == _book.value?.bookUrl) { - _downloadingIndices.value = set - } - } - } - - launch { - CacheBook.downloadErrorFlow.collect { (url, set) -> - if (url == _book.value?.bookUrl) { - _errorIndices.value = set - } - } - } - - launch { - CacheBook.downloadSummaryFlow.collect { - _downloadSummary.value = it - } - } - - launch { - CacheBook.cacheSuccessFlow.collect { chapter -> - if (chapter.bookUrl == _book.value?.bookUrl) { - _cacheFileNames.update { - it + chapter.getFileName() - } - } - } - } - } - } - - fun initBook(bookUrl: String) = execute { - appDb.bookDao.getBook(bookUrl)?.let { book -> - _book.value = book - _isReverse.value = book.getReverseToc() - - viewModelScope.launch(Dispatchers.IO) { - val files = BookHelp.getChapterFiles(book).toSet() - _cacheFileNames.value = files - } - } - } - - fun saveTocRegex(newRegex: String) { - val book = _book.value ?: return - book.tocUrl = newRegex - upBookTocRule(book) { error -> - if (error != null) { - context.toastOnUi("更新目录规则失败: ${error.localizedMessage}") - } else { - context.toastOnUi("目录规则已更新") - if (ReadBook.book?.bookUrl == book.bookUrl) { - ReadBook.upMsg(null) - } - } - } - } - - fun toggleSplitLongChapter() { - val book = _book.value ?: return - val newState = !isSplitLongChapter - book.setSplitLongChapter(newState) - - upBookTocRule(book) { error -> - if (error != null) { - context.toastOnUi("设置失败: ${error.localizedMessage}") - } else { - context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分") - } - } - } - - private fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) { - _isUploading.value = true - execute { - appDb.bookDao.update(book) - LocalBook.getChapterList(book).let { chapters -> - appDb.bookChapterDao.delByBook(book.bookUrl) - appDb.bookChapterDao.insert(*chapters.toTypedArray()) - appDb.bookDao.update(book) - ReadBook.onChapterListUpdated(book) - - _book.value = book - } - }.onSuccess { - _isUploading.value = false - complete.invoke(null) - }.onError { - _isUploading.value = false - complete.invoke(it) - } - } - - fun reverseToc() = execute { - val currentBook = _book.value ?: return@execute - - val newReverseState = !_isReverse.value - _isReverse.value = newReverseState - - val newBook = currentBook.copy().apply { - setReverseToc(newReverseState) - } - appDb.bookDao.update(newBook) - _book.value = newBook - } - - fun toggleVolume(volumeName: String) { + fun toggleVolume(volumeIndex: Int) { _collapsedVolumes.update { current -> - if (current.contains(volumeName)) current - volumeName else current + volumeName + if (current.contains(volumeIndex)) current - volumeIndex else current + volumeIndex } } @@ -361,11 +369,10 @@ class TocViewModel(application: Application) : } fun collapseAllVolumes() = execute { - val bookUrl = _book.value?.bookUrl ?: return@execute - val volumes = appDb.bookChapterDao.getChapterList(bookUrl) - .filter { it.isVolume } - .map { it.title } - .toSet() + val bookUrl = bookState.value?.bookUrl ?: return@execute + val volumes = + appDb.bookChapterDao.getChapterList(bookUrl).filter { it.isVolume }.map { it.index } + .toSet() _collapsedVolumes.value = volumes } @@ -382,50 +389,82 @@ class TocViewModel(application: Application) : setSelection(emptySet()) } - fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) { - viewModelScope.launch { - try { - val book = _book.value ?: return@launch - val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author) + fun selectFromLast() { + val currentItems = uiState.value.items + val maxSelectedId = _selectedIds.value.maxOrNull() ?: return + val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId } + if (maxIndex == -1) return + setSelection(_selectedIds.value + currentItems.drop(maxIndex + 1).map { it.id }) + } - if (bookmarks.isEmpty()) { - context.toastOnUi("没有可导出的书签") - return@launch - } - - BookmarkExporter.exportToUri( - context = getApplication(), - fileUri = fileUri, - bookmarks = bookmarks, - isMd = isMd, - bookName = book.name, - author = book.author - ) - - context.toastOnUi("保存成功") - } catch (e: Exception) { - context.toastOnUi("保存失败: ${e.message}") + fun saveTocRegex(newRegex: String) { + val book = bookState.value ?: return + book.tocUrl = newRegex + upBookTocRule(book) { error -> + if (error != null) context.toastOnUi("更新目录规则失败: ${error.localizedMessage}") + else { + context.toastOnUi("目录规则已更新") + if (ReadBook.book?.bookUrl == book.bookUrl) ReadBook.upMsg(null) } } } - fun updateBookmark(bookmark: Bookmark) { - viewModelScope.launch(Dispatchers.IO) { - appDb.bookmarkDao.insert(bookmark) + fun toggleSplitLongChapter() { + val book = bookState.value ?: return + val newState = !isSplitLongChapter + book.setSplitLongChapter(newState) + upBookTocRule(book) { error -> + if (error != null) context.toastOnUi("设置失败: ${error.localizedMessage}") + else context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分") } } - fun deleteBookmark(bookmark: Bookmark) { - viewModelScope.launch(Dispatchers.IO) { - appDb.bookmarkDao.delete(bookmark) + private fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) { + _isUploading.value = true + execute { + appDb.bookDao.update(book) + LocalBook.getChapterList(book).let { chapters -> + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) + appDb.bookDao.update(book) + ReadBook.onChapterListUpdated(book) + //bookState.value = book + } + }.onSuccess { + _isUploading.value = false + complete.invoke(null) + }.onError { + _isUploading.value = false + complete.invoke(it) } } - /** - * 下载选中的章节 - */ + fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) = viewModelScope.launch { + try { + val book = bookState.value ?: return@launch + val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author) + if (bookmarks.isEmpty()) { + context.toastOnUi("没有可导出的书签") + return@launch + } + BookmarkExporter.exportToUri( + context = getApplication(), fileUri = fileUri, bookmarks = bookmarks, + isMd = isMd, bookName = book.name, author = book.author + ) + context.toastOnUi("保存成功") + } catch (e: Exception) { + context.toastOnUi("保存失败: ${e.message}") + } + } + + fun updateBookmark(bookmark: Bookmark) = + viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.insert(bookmark) } + + fun deleteBookmark(bookmark: Bookmark) = + viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.delete(bookmark) } + fun downloadSelected() { - val book = _book.value ?: return + val book = bookState.value ?: return val indices = uiState.value.selectedIds.toList() if (indices.isEmpty()) return CacheBook.start(getApplication(), book, indices) @@ -433,24 +472,16 @@ class TocViewModel(application: Application) : clearSelection() } - /** - * 下载单个章节 - */ fun downloadChapter(index: Int) { - val book = _book.value ?: return + val book = bookState.value ?: return CacheBook.start(getApplication(), book, listOf(index)) getApplication().toastOnUi("开始下载章节") } - /** - * 下载所有章节 - */ fun downloadAll() { - val book = _book.value ?: return - val cachedFiles = _cacheFileNames.value - + val book = bookState.value ?: return val targetIndices = uiState.value.items - .filter { !it.chapter.isVolume && it.chapter.getFileName() !in cachedFiles } + .filter { !it.isVolume && it.downloadState != DownloadState.SUCCESS } .map { it.id } if (targetIndices.isEmpty()) { @@ -462,14 +493,17 @@ class TocViewModel(application: Application) : getApplication().toastOnUi("开始下载剩余 ${targetIndices.size} 个章节") } - fun selectFromLast() { - val currentItems = uiState.value.items - val maxSelectedId = _selectedIds.value.maxOrNull() ?: return - - val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId } - if (maxIndex == -1) return - - val idsToAppend = currentItems.drop(maxIndex + 1).map { it.id } - setSelection(_selectedIds.value + idsToAppend) + private fun List.groupAndReverseVolumes(): List { + return this.fold(mutableListOf>()) { acc, chapter -> + if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter)) + else acc.last().add(chapter) + acc + }.asReversed().flatMap { group -> + if (group.firstOrNull()?.isVolume == true) { + listOf(group.first()) + group.drop(1).asReversed() + } else { + group.asReversed() + } + } } } \ No newline at end of file