Explorar o código

[优化] 优化目录页面性能和界面

HapeLee hai 8 meses
pai
achega
b31f56b953

+ 3 - 0
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<Book?>
+
     @Query("SELECT * FROM books WHERE name = :name and author = :author")
     fun getBook(name: String, author: String): Book?
 

+ 1 - 1
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)
         }
     }
 

+ 4 - 4
app/src/main/java/io/legado/app/model/CacheBook.kt

@@ -52,14 +52,14 @@ object CacheBook {
     val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
 
     private val _downloadingIndicesFlow =
-        MutableSharedFlow<Pair<String, Set<Int>>>(extraBufferCapacity = 64)
+        MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
 
-    val downloadingIndicesFlow = _downloadingIndicesFlow.asSharedFlow()
+    val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
 
     private val _downloadErrorFlow =
-        MutableSharedFlow<Pair<String, Set<Int>>>(extraBufferCapacity = 64)
+        MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
 
-    val downloadErrorFlow = _downloadErrorFlow.asSharedFlow()
+    val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
 
     private fun updateSummary() {
         _downloadSummaryFlow.value = downloadSummary

+ 2 - 1
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
 }

+ 8 - 6
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()
+            }
         )
     }
 }

+ 192 - 195
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}") {
+            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
-                    }
+            } else {
 
+                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)
+                )
             }
         }
     }

+ 326 - 292
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,156 +29,254 @@ 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
-
-data class TocUiItem(
-    val chapter: BookChapter,
-    val displayTitle: String,
-    override val id: Int = chapter.index
+import kotlinx.coroutines.withContext
+
+@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<Int>
 
+@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<TocUiItem> = emptyList(),
+    override val items: List<TocItemUi> = emptyList(),
     override val selectedIds: Set<Int> = emptySet(),
     override val searchKey: String = "",
     override val isSearch: Boolean = false,
     override val isUploading: Boolean = false,
-    val downloadingIndices: Set<Int> = emptySet(),
-    val errorIndices: Set<Int> = emptySet(),
-    val cachedFiles: Set<String> = emptySet(),
     val downloadSummary: String = ""
-) : RuleActionState<TocUiItem>
+) : RuleActionState<TocItemUi>
 
-@OptIn(ExperimentalCoroutinesApi::class)
-class TocViewModel(application: Application) :
-    BaseRuleViewModel<TocUiItem, Pair<BookChapter, String>, Int, TocActionState>(
-        application,
-        initialState = TocActionState()
-    ) {
-
-    private val _book = MutableStateFlow<Book?>(null)
-    val bookState = _book.asStateFlow()
-    val isSplitLongChapter: Boolean
-        get() = _book.value?.getSplitLongChapter() ?: false
-
-    private val _collapsedVolumes = MutableStateFlow<Set<String>>(emptySet())
-    val collapsedVolumes = _collapsedVolumes.asStateFlow()
+data class TocDomainItem(
+    val chapter: BookChapter,
+    val displayTitle: String,
+    val downloadState: DownloadState
+)
 
-    private val _cacheFileNames = MutableStateFlow<Set<String>>(emptySet())
-    val cacheFileNames = _cacheFileNames.asStateFlow()
+private data class DownloadContext(
+    val downloadingPair: Pair<String, Set<Int>>,
+    val errorPair: Pair<String, Set<Int>>,
+    val cachedFiles: Set<String>
+)
 
-    private val _isReverse = MutableStateFlow(false)
+private data class TocUiConfig(
+    val collapsedVolumes: Set<Int>,
+    val useReplace: Boolean,
+    val showWordCount: Boolean,
+    val isReverse: Boolean
+)
 
-    private val _downloadingIndices = MutableStateFlow<Set<Int>>(emptySet())
-    private val _downloadSummary = MutableStateFlow("")
+data class FabAction(val icon: ImageVector, val label: String, val action: () -> Unit)
 
-    private val _errorIndices = MutableStateFlow<Set<Int>>(emptySet())
-    val errorIndices = _errorIndices.asStateFlow()
+@OptIn(ExperimentalCoroutinesApi::class)
+class TocViewModel(
+    application: Application,
+    savedStateHandle: SavedStateHandle
+) : BaseRuleViewModel<TocItemUi, TocDomainItem, Int, TocActionState>(
+    application,
+    initialState = TocActionState()
+) {
+
+    private val bookUrlFlow = savedStateHandle.getStateFlow<String?>("bookUrl", null)
+    val bookState = bookUrlFlow
+        .filterNotNull()
+        .flatMapLatest { url ->
+            appDb.bookDao.flowGetBook(url)
+        }
+        .stateIn(viewModelScope, SharingStarted.Eagerly, null)
 
-    private val _bookmarks = MutableStateFlow<List<Bookmark>>(emptyList())
-    val bookmarks = _bookmarks.asStateFlow()
+    val isSplitLongChapter: Boolean get() = bookState.value?.getSplitLongChapter() ?: false
 
-    @OptIn(ExperimentalCoroutinesApi::class)
-    private val dbChapterListFlow = _book
-        .filterNotNull()
+    private val _collapsedVolumes = MutableStateFlow<Set<Int>>(emptySet())
+    val collapsedVolumes = _collapsedVolumes.asStateFlow()
+
+    val downloadSummary: StateFlow<String> =
+        CacheBook.downloadSummaryFlow
+            .stateIn(
+                viewModelScope,
+                SharingStarted.WhileSubscribed(5000),
+                ""
+            )
+
+    private val _cacheFileNames: StateFlow<Set<String>> = 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<List<Pair<BookChapter, String>>> = combine(
-        dbChapterListFlow,
-        _isReverse,
-        _book.filterNotNull(),
-        snapshotFlow { ReadConfig.tocUiUseReplace },
-        snapshotFlow { ReadConfig.tocCountWords }
-    ) { originalList, isReverse, book, useReplace, showWordCount ->
-
-        val processedList = if (isReverse) {
-            originalList.fold(mutableListOf<MutableList<BookChapter>>()) { 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<List<TocBookmarkItemUi>> =
+        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<List<TocDomainItem>> = 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 }
-
-    override val rawDataFlow =
-        combine(processedChaptersFlow, _collapsedVolumes, downloadStateTrigger) { list, _, _ ->
-            list
+        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()
+
+        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
 
-    fun toggleUseReplace() {
-        ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace
-    }
-
-    fun toggleShowWordCount() {
-        ReadConfig.tocCountWords = !ReadConfig.tocCountWords
-    }
-
-    override fun filterData(
-        data: List<Pair<BookChapter, String>>,
-        key: String
-    ): List<Pair<BookChapter, String>> {
+    override fun filterData(data: List<TocDomainItem>, key: String): List<TocDomainItem> {
         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)
+                if (item.chapter.isVolume) {
+                    isCurrentVolumeCollapsed = collapsed.contains(item.chapter.index)
                 } else if (isCurrentVolumeCollapsed && !isSearch) {
                     continue
                 }
 
-                if (!isSearch || displayTitle.contains(
-                        key,
-                        ignoreCase = true
-                    ) || chapter.isVolume
-                ) {
+                if (!isSearch || item.displayTitle.contains(key, true) || item.chapter.isVolume) {
                     add(item)
                 }
             }
@@ -182,137 +284,138 @@ class TocViewModel(application: Application) :
     }
 
     override fun composeUiState(
-        items: List<TocUiItem>,
+        items: List<TocItemUi>,
         selectedIds: Set<Int>,
         isSearch: Boolean,
         isUploading: Boolean,
-        importState: BaseImportUiState<Pair<BookChapter, String>>
+        importState: BaseImportUiState<TocDomainItem>
     ): 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 = items,
+            items = updatedItems,
             selectedIds = selectedIds,
             searchKey = _searchKey.value,
             isSearch = isSearch,
             isUploading = isUploading,
-            downloadingIndices = _downloadingIndices.value,
-            errorIndices = _errorIndices.value,
-            cachedFiles = _cacheFileNames.value,
-            downloadSummary = _downloadSummary.value
+            downloadSummary = downloadSummary.value
         )
     }
 
-    override fun Pair<BookChapter, String>.toUiItem() = TocUiItem(first, second)
+    override fun TocDomainItem.toUiItem(): TocItemUi {
+        val wordCountText = if (showWordCount) {
+            chapter.wordCount
+        } else {
+            null
+        }
 
-    override fun ruleItemToEntity(item: TocUiItem) = item.chapter to item.displayTitle
+        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 suspend fun generateJson(entities: List<Pair<BookChapter, String>>) = ""
-    override fun parseImportRules(text: String): List<Pair<BookChapter, String>> = emptyList()
-    override fun hasChanged(
-        newRule: Pair<BookChapter, String>,
-        oldRule: Pair<BookChapter, String>
-    ) = false
+    override fun ruleItemToEntity(item: TocItemUi): TocDomainItem {
+        throw NotImplementedError("TOC 不需要向后反转实体")
+    }
 
-    override suspend fun findOldRule(newRule: Pair<BookChapter, String>) = null
+    override suspend fun generateJson(entities: List<TocDomainItem>) = ""
+    override fun parseImportRules(text: String): List<TocDomainItem> = emptyList()
+    override fun hasChanged(newRule: TocDomainItem, oldRule: TocDomainItem) = false
+    override suspend fun findOldRule(newRule: TocDomainItem) = 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
-                }
-        }
+    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
+    }
 
-        viewModelScope.launch {
+    fun toggleUseReplace() {
+        ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace
+    }
 
-            launch {
-                CacheBook.downloadingIndicesFlow.collect { (url, set) ->
-                    if (url == _book.value?.bookUrl) {
-                        _downloadingIndices.value = set
-                    }
-                }
-            }
+    fun toggleShowWordCount() {
+        ReadConfig.tocCountWords = !ReadConfig.tocCountWords
+    }
 
-            launch {
-                CacheBook.downloadErrorFlow.collect { (url, set) ->
-                    if (url == _book.value?.bookUrl) {
-                        _errorIndices.value = set
-                    }
-                }
-            }
+    fun toggleVolume(volumeIndex: Int) {
+        _collapsedVolumes.update { current ->
+            if (current.contains(volumeIndex)) current - volumeIndex else current + volumeIndex
+        }
+    }
 
-            launch {
-                CacheBook.downloadSummaryFlow.collect {
-                    _downloadSummary.value = it
-                }
-            }
+    fun expandAllVolumes() {
+        _collapsedVolumes.value = emptySet()
+    }
 
-            launch {
-                CacheBook.cacheSuccessFlow.collect { chapter ->
-                    if (chapter.bookUrl == _book.value?.bookUrl) {
-                        _cacheFileNames.update {
-                            it + chapter.getFileName()
-                        }
-                    }
-                }
-            }
-        }
+    fun collapseAllVolumes() = execute {
+        val bookUrl = bookState.value?.bookUrl ?: return@execute
+        val volumes =
+            appDb.bookChapterDao.getChapterList(bookUrl).filter { it.isVolume }.map { it.index }
+                .toSet()
+        _collapsedVolumes.value = volumes
     }
 
-    fun initBook(bookUrl: String) = execute {
-        appDb.bookDao.getBook(bookUrl)?.let { book ->
-            _book.value = book
-            _isReverse.value = book.getReverseToc()
+    fun selectAll() {
+        setSelection(uiState.value.items.map { it.id }.toSet())
+    }
 
-            viewModelScope.launch(Dispatchers.IO) {
-                val files = BookHelp.getChapterFiles(book).toSet()
-                _cacheFileNames.value = files
-            }
-        }
+    fun invertSelection() {
+        val allIds = uiState.value.items.map { it.id }.toSet()
+        setSelection(allIds - _selectedIds.value)
+    }
+
+    fun clearSelection() {
+        setSelection(emptySet())
+    }
+
+    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 })
     }
 
     fun saveTocRegex(newRegex: String) {
-        val book = _book.value ?: return
+        val book = bookState.value ?: return
         book.tocUrl = newRegex
         upBookTocRule(book) { error ->
-            if (error != null) {
-                context.toastOnUi("更新目录规则失败: ${error.localizedMessage}")
-            } else {
+            if (error != null) context.toastOnUi("更新目录规则失败: ${error.localizedMessage}")
+            else {
                 context.toastOnUi("目录规则已更新")
-                if (ReadBook.book?.bookUrl == book.bookUrl) {
-                    ReadBook.upMsg(null)
-                }
+                if (ReadBook.book?.bookUrl == book.bookUrl) ReadBook.upMsg(null)
             }
         }
     }
 
     fun toggleSplitLongChapter() {
-        val book = _book.value ?: return
+        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 "已关闭长章节拆分")
-            }
+            if (error != null) context.toastOnUi("设置失败: ${error.localizedMessage}")
+            else context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分")
         }
     }
 
@@ -325,8 +428,7 @@ class TocViewModel(application: Application) :
                 appDb.bookChapterDao.insert(*chapters.toTypedArray())
                 appDb.bookDao.update(book)
                 ReadBook.onChapterListUpdated(book)
-
-                _book.value = book
+                //bookState.value = book
             }
         }.onSuccess {
             _isUploading.value = false
@@ -337,95 +439,32 @@ class TocViewModel(application: Application) :
         }
     }
 
-    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) {
-        _collapsedVolumes.update { current ->
-            if (current.contains(volumeName)) current - volumeName else current + volumeName
-        }
-    }
-
-    fun expandAllVolumes() {
-        _collapsedVolumes.value = emptySet()
-    }
-
-    fun collapseAllVolumes() = execute {
-        val bookUrl = _book.value?.bookUrl ?: return@execute
-        val volumes = appDb.bookChapterDao.getChapterList(bookUrl)
-            .filter { it.isVolume }
-            .map { it.title }
-            .toSet()
-        _collapsedVolumes.value = volumes
-    }
-
-    fun selectAll() {
-        setSelection(uiState.value.items.map { it.id }.toSet())
-    }
-
-    fun invertSelection() {
-        val allIds = uiState.value.items.map { it.id }.toSet()
-        setSelection(allIds - _selectedIds.value)
-    }
-
-    fun clearSelection() {
-        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)
-
-                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 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 updateBookmark(bookmark: Bookmark) =
+        viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.insert(bookmark) }
 
-    fun deleteBookmark(bookmark: Bookmark) {
-        viewModelScope.launch(Dispatchers.IO) {
-            appDb.bookmarkDao.delete(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<Application>().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<Application>().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<BookChapter>.groupAndReverseVolumes(): List<BookChapter> {
+        return this.fold(mutableListOf<MutableList<BookChapter>>()) { 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()
+            }
+        }
     }
 }