Explorar o código

feat: 目录Sheet

HapeLee hai 3 meses
pai
achega
6c848ad762

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -20,6 +20,7 @@ import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
+import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -888,8 +889,6 @@ sealed interface ReadBookEffect {
     // Source actions
     data class ShowLogin(val sourceUrl: String) : ReadBookEffect
     data class OpenSourceEdit(val sourceUrl: String) : ReadBookEffect
-    data class OpenBookInfo(val name: String, val author: String, val bookUrl: String) : ReadBookEffect
-    data class OpenChapterList(val bookUrl: String) : ReadBookEffect
     data class OpenWebView(
         val title: String,
         val url: String,
@@ -975,6 +974,7 @@ sealed interface ReadBookEffect {
 
 @Immutable
 sealed interface ReadBookSheet {
+    data class BookNavigation(val initialTab: ReaderBookSheetTab) : ReadBookSheet
     data object PageAnim : ReadBookSheet
     data object Download : ReadBookSheet
     data object Charset : ReadBookSheet

+ 0 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -1053,9 +1053,7 @@ class ReadBookController(
             }
 
             // Launcher-dependent effects — handled by route layer, ignored here
-            is ReadBookEffect.OpenChapterList,
             is ReadBookEffect.OpenSourceEdit,
-            is ReadBookEffect.OpenBookInfo,
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.OpenWebView,

+ 23 - 17
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -54,10 +54,10 @@ import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
-import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
@@ -174,12 +174,6 @@ fun ReadBookRouteScreen(
 
     // ── ActivityResult Launchers ──────────────────────────────────────
 
-    val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
-        result?.let { (index, chapterPos, _) ->
-            viewModel.onIntent(ReadBookIntent.OpenChapterResult(index, chapterPos))
-        }
-    }
-
     val sourceEditLauncher = rememberLauncherForActivityResult(
         StartActivityContract(BookSourceEditActivity::class.java)
     ) { result ->
@@ -322,19 +316,9 @@ fun ReadBookRouteScreen(
                     try {
                         when (effect) {
                             // Launcher-dependent effects — handled directly by route
-                            is ReadBookEffect.OpenChapterList -> {
-                                tocLauncher.launch(effect.bookUrl)
-                            }
                             is ReadBookEffect.OpenSourceEdit -> {
                                 sourceEditLauncher.launch { putExtra("sourceUrl", effect.sourceUrl) }
                             }
-                            is ReadBookEffect.OpenBookInfo -> {
-                                bookInfoLauncher.launch {
-                                    putExtra("name", effect.name)
-                                    putExtra("author", effect.author)
-                                    putExtra("bookUrl", effect.bookUrl)
-                                }
-                            }
                             is ReadBookEffect.ShowLogin -> {
                                 context.startActivity(
                                     Intent(context, SourceLoginActivity::class.java).apply {
@@ -600,6 +584,28 @@ fun ReadBookRouteScreen(
                     showSelectMenuConfigSheet = true
                 },
             )
+            val bookNavigationSheet = state.activeSheet as? ReadBookSheet.BookNavigation
+            ReaderBookSheetRoute(
+                show = bookNavigationSheet != null,
+                bookUrl = state.book?.bookUrl.orEmpty(),
+                initialTab = bookNavigationSheet?.initialTab
+                    ?: io.legado.app.ui.book.read.sheet.ReaderBookSheetTab.Information,
+                onDismissRequest = { viewModel.onIntent(ReadBookIntent.DismissSheet) },
+                onChapterClick = { index, chapterPos ->
+                    viewModel.onIntent(ReadBookIntent.DismissSheet)
+                    viewModel.onIntent(ReadBookIntent.OpenChapterResult(index, chapterPos))
+                },
+                onOpenFullBookInfo = {
+                    state.book?.let { book ->
+                        viewModel.onIntent(ReadBookIntent.DismissSheet)
+                        bookInfoLauncher.launch {
+                            putExtra("name", book.name)
+                            putExtra("author", book.author)
+                            putExtra("bookUrl", book.bookUrl)
+                        }
+                    }
+                },
+            )
             TextActionSelectionMenu(
                 menuState = textMenuState,
                 expandTextMenu = readPreferences.expandTextMenu,

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -529,6 +529,8 @@ fun ReadBookScreen(
 
         is ReadBookSheet.Bookmark -> Unit
 
+        is ReadBookSheet.BookNavigation -> Unit
+
         is ReadBookSheet.InfoConfig -> {
             // Integrated into ReadStyleSheet's HeaderFooterPage
             LaunchedEffect(state.activeSheet) {

+ 16 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -665,15 +665,27 @@ class ReadBookViewModel(
                 }
             }
             is ReadBookIntent.OpenBookInfo -> {
-                ReadBook.book?.let { book ->
+                if (ReadBook.book != null) {
                     closeReadMenu()
-                    _effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl))
+                    _uiState.update {
+                        it.copy(
+                            activeSheet = ReadBookSheet.BookNavigation(
+                                io.legado.app.ui.book.read.sheet.ReaderBookSheetTab.Information
+                            )
+                        )
+                    }
                 }
             }
             is ReadBookIntent.OpenChapterList -> {
-                ReadBook.book?.bookUrl?.let { bookUrl ->
+                if (ReadBook.book != null) {
                     closeReadMenu()
-                    _effects.tryEmit(ReadBookEffect.OpenChapterList(bookUrl))
+                    _uiState.update { state ->
+                        state.copy(
+                            activeSheet = ReadBookSheet.BookNavigation(
+                                io.legado.app.ui.book.read.sheet.ReaderBookSheetTab.Toc
+                            )
+                        )
+                    }
                 }
             }
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()

+ 1134 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt

@@ -0,0 +1,1134 @@
+package io.legado.app.ui.book.read.sheet
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.BackHandler
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.MenuOpen
+import androidx.compose.material.icons.filled.AutoFixHigh
+import androidx.compose.material.icons.filled.BookmarkAdd
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Numbers
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.SwapVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Shuffle
+import androidx.compose.material.icons.outlined.DownloadForOffline
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.stringResource
+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.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.Bookmark
+import io.legado.app.help.book.isLocal
+import io.legado.app.ui.book.toc.TocEffect
+import io.legado.app.ui.book.toc.TocIntent
+import io.legado.app.ui.book.toc.TocBookmarkItemUi
+import io.legado.app.ui.book.toc.TocItemUi
+import io.legado.app.ui.book.toc.TocUiState
+import io.legado.app.ui.book.toc.TocViewModel
+import io.legado.app.ui.book.toc.DownloadState
+import io.legado.app.ui.book.toc.TocActivity
+import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
+import io.legado.app.ui.replace.ReplaceEditRoute
+import io.legado.app.ui.replace.ReplaceRuleActivity
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButtonMenu
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.FabMenuItem
+import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+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.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
+import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
+import io.legado.app.ui.widget.components.tabRow.CardTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.koin.androidx.compose.koinViewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+enum class ReaderBookSheetTab {
+    Information,
+    Toc,
+    Bookmarks,
+}
+
+@Composable
+fun ReaderBookSheetRoute(
+    show: Boolean,
+    bookUrl: String,
+    initialTab: ReaderBookSheetTab,
+    onDismissRequest: () -> Unit,
+    onChapterClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
+    onOpenFullBookInfo: () -> Unit,
+    viewModel: TocViewModel = koinViewModel(key = "reader-book-sheet-$bookUrl"),
+) {
+    val state by viewModel.screenState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    var pendingExportMarkdown by remember { mutableStateOf(false) }
+
+    val exportLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.CreateDocument("*/*")
+    ) { uri: Uri? ->
+        uri?.let { viewModel.onIntent(TocIntent.ExportBookmarks(it, pendingExportMarkdown)) }
+    }
+    val tocRegexLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) { result ->
+        if (result.resultCode == Activity.RESULT_OK) {
+            viewModel.onIntent(
+                TocIntent.SaveTocRegex(result.data?.getStringExtra("tocRegex").orEmpty())
+            )
+        }
+    }
+    val fullTocLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) { result ->
+        if (result.resultCode == Activity.RESULT_OK) {
+            onChapterClick(
+                result.data?.getIntExtra("index", 0) ?: 0,
+                result.data?.getIntExtra("chapterPos", 0) ?: 0,
+            )
+        }
+    }
+
+    LaunchedEffect(bookUrl) {
+        viewModel.onIntent(TocIntent.LoadBook(bookUrl))
+    }
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is TocEffect.ShowMessage ->
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+            }
+        }
+    }
+
+    ReaderBookSheet(
+        show = show,
+        state = state,
+        initialTab = initialTab,
+        onDismissRequest = {
+            viewModel.onIntent(TocIntent.ClearSelection)
+            viewModel.onIntent(TocIntent.SetSearchQuery(""))
+            onDismissRequest()
+        },
+        onIntent = viewModel::onIntent,
+        onChapterClick = { index -> onChapterClick(index, 0) },
+        onBookmarkNavigate = onChapterClick,
+        onOpenFullScreen = { tab ->
+            when (tab) {
+                ReaderBookSheetTab.Information -> onOpenFullBookInfo()
+                ReaderBookSheetTab.Toc,
+                ReaderBookSheetTab.Bookmarks -> {
+                    fullTocLauncher.launch(
+                        Intent(context, TocActivity::class.java)
+                            .putExtra("bookUrl", bookUrl)
+                            .putExtra(
+                                "initialPage",
+                                if (tab == ReaderBookSheetTab.Bookmarks) 1 else 0,
+                            )
+                    )
+                }
+            }
+        },
+        onOpenReplaceRule = { route ->
+            context.startActivity(ReplaceRuleActivity.startIntent(context, route))
+        },
+        onEditLocalTocRule = { regex ->
+            tocRegexLauncher.launch(
+                Intent(context, TxtTocRuleActivity::class.java).putExtra("tocRegex", regex)
+            )
+        },
+        onExportBookmarks = { isMarkdown, fileName ->
+            pendingExportMarkdown = isMarkdown
+            exportLauncher.launch(fileName)
+        },
+    )
+}
+
+@Composable
+private fun ReaderBookSheet(
+    show: Boolean,
+    state: TocUiState,
+    initialTab: ReaderBookSheetTab,
+    onDismissRequest: () -> Unit,
+    onIntent: (TocIntent) -> Unit,
+    onChapterClick: (Int) -> Unit,
+    onBookmarkNavigate: (Int, Int) -> Unit,
+    onOpenFullScreen: (ReaderBookSheetTab) -> Unit,
+    onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
+    onEditLocalTocRule: (String?) -> Unit,
+    onExportBookmarks: (isMarkdown: Boolean, fileName: String) -> Unit,
+) {
+    val initialPage = initialTab.ordinal
+    val pagerState = rememberPagerState(initialPage = initialPage) { ReaderBookSheetTab.entries.size }
+    val scope = rememberCoroutineScope()
+    var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
+
+    val density = LocalDensity.current
+    val maxHeight = with(density) {
+        LocalWindowInfo.current.containerSize.height.toDp() * 0.72f
+    }
+
+    LaunchedEffect(show, initialPage) {
+        if (show) pagerState.scrollToPage(initialPage)
+    }
+    BackHandler(enabled = show && state.action.selectedIds.isNotEmpty()) {
+        onIntent(TocIntent.ClearSelection)
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        animateContentSize = false,
+        modifier = Modifier
+            .heightIn(max = maxHeight),
+    ) {
+        ReaderBookHeader(book = state.book)
+        CardTabRow(
+            tabTitles = listOf(
+                stringResource(R.string.information),
+                stringResource(R.string.chapter_list),
+                stringResource(R.string.bookmark),
+            ),
+            selectedTabIndex = pagerState.currentPage,
+            onTabSelected = {
+                onIntent(TocIntent.SetSearchQuery(""))
+                onIntent(TocIntent.ClearSelection)
+                scope.launch { pagerState.animateScrollToPage(it) }
+            },
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(top = 8.dp),
+            tabEndContent = { index ->
+                Box(
+                    modifier = Modifier
+                        .padding(start = 8.dp)
+                        .size(16.dp)
+                        .clip(RoundedCornerShape(6.dp))
+                        .clickable {
+                        onDismissRequest()
+                        onOpenFullScreen(ReaderBookSheetTab.entries[index])
+                    },
+                    contentAlignment = Alignment.Center,
+                ) {
+                    Icon(
+                        Icons.AutoMirrored.Filled.MenuOpen,
+                        contentDescription = stringResource(R.string.open),
+                        modifier = Modifier.size(16.dp),
+                    )
+                }
+            },
+        )
+        HorizontalPager(
+            state = pagerState,
+            modifier = Modifier
+                .fillMaxWidth()
+                .weight(1f),
+        ) { page ->
+            when (ReaderBookSheetTab.entries[page]) {
+                ReaderBookSheetTab.Information -> ReaderBookInformation(
+                    book = state.book,
+                )
+                ReaderBookSheetTab.Toc -> ReaderBookTocPage(
+                    state = state,
+                    onIntent = onIntent,
+                    onChapterClick = onChapterClick,
+                    onOpenReplaceRule = onOpenReplaceRule,
+                    onEditLocalTocRule = onEditLocalTocRule,
+                )
+                ReaderBookSheetTab.Bookmarks -> ReaderBookBookmarksPage(
+                    state = state,
+                    onIntent = onIntent,
+                    onBookmarkNavigate = onBookmarkNavigate,
+                    onEditBookmark = { editingBookmark = it },
+                    onExportBookmarks = onExportBookmarks,
+                )
+            }
+        }
+    }
+
+    val bookmark = editingBookmark
+    BookmarkEditSheet(
+        show = bookmark != null,
+        bookmark = bookmark ?: remember { Bookmark() },
+        onDismiss = { editingBookmark = null },
+        onSave = {
+            onIntent(TocIntent.UpdateBookmark(it))
+            editingBookmark = null
+        },
+        onDelete = {
+            onIntent(TocIntent.DeleteBookmark(it))
+            editingBookmark = null
+        },
+    )
+}
+
+@Composable
+private fun ReaderBookHeader(book: Book?) {
+    val current = ((book?.durChapterIndex ?: -1) + 1).coerceAtLeast(0)
+    val total = book?.totalChapterNum?.coerceAtLeast(0) ?: 0
+
+    Row(
+        modifier = Modifier
+            .fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        CoilBookCover(
+            name = book?.name,
+            author = book?.author,
+            path = book?.getDisplayCover(),
+            sourceOrigin = book?.origin,
+            modifier = Modifier.width(40.dp),
+        )
+        Column(
+            modifier = Modifier.weight(1f),
+        ) {
+            AppText(
+                text = book?.name.orEmpty(),
+                style = LegadoTheme.typography.labelLargeEmphasized,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+            )
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                AppText(
+                    modifier = Modifier
+                        .padding(end = 16.dp)
+                        .basicMarquee()
+                        .weight(1f),
+                    text = book?.durChapterTitle.orEmpty(),
+                    style = LegadoTheme.typography.labelSmallEmphasized,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                TextCard(
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
+                    text = if (total > 0) "$current / $total" else "--",
+                )
+            }
+
+        }
+    }
+}
+
+@Composable
+private fun ReaderBookInformation(
+    book: Book?,
+) {
+    LazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        item { InformationRow(stringResource(R.string.author), book?.author.orEmpty()) }
+        item {
+            InformationRow(
+                stringResource(R.string.book_source),
+                book?.originName?.ifBlank { book.origin }.orEmpty(),
+            )
+        }
+        book?.remark?.takeIf { it.isNotBlank() }?.let { remark ->
+            item { InformationRow(stringResource(R.string.book_remark), remark) }
+        }
+        book?.getDisplayIntro()?.takeIf { it.isNotBlank() }?.let { intro ->
+            item { InformationRow(stringResource(R.string.book_intro), intro) }
+        }
+    }
+}
+
+@Composable
+private fun InformationRow(label: String, value: String) {
+    NormalCard(
+        cornerRadius = 14.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        modifier = Modifier.fillMaxWidth(),
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalArrangement = Arrangement.spacedBy(3.dp),
+        ) {
+            AppText(
+                text = label,
+                style = LegadoTheme.typography.labelMediumEmphasized,
+                color = LegadoTheme.colorScheme.primary,
+            )
+            AppText(
+                text = value.ifBlank { "--" },
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+    }
+}
+
+@Composable
+private fun CompactSearchField(
+    query: String,
+    onQueryChange: (String) -> Unit,
+    placeholder: String,
+    modifier: Modifier = Modifier,
+) {
+    val shape = RoundedCornerShape(10.dp)
+    BasicTextField(
+        value = query,
+        onValueChange = onQueryChange,
+        modifier = modifier
+            .height(32.dp)
+            .clip(shape)
+            .background(LegadoTheme.colorScheme.surfaceContainerLow),
+        singleLine = true,
+        textStyle = LegadoTheme.typography.bodySmall.copy(
+            color = LegadoTheme.colorScheme.onSurface,
+        ),
+        cursorBrush = SolidColor(LegadoTheme.colorScheme.primary),
+        decorationBox = { innerTextField ->
+            Row(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(horizontal = 8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(6.dp),
+            ) {
+                Icon(
+                    imageVector = Icons.Default.Search,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    modifier = Modifier.size(14.dp),
+                )
+                Box(
+                    modifier = Modifier.weight(1f),
+                    contentAlignment = Alignment.CenterStart,
+                ) {
+                    if (query.isEmpty()) {
+                        AppText(
+                            text = placeholder,
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                    }
+                    innerTextField()
+                }
+            }
+        },
+    )
+}
+
+@Composable
+private fun CompactToolIconBox(
+    selected: Boolean,
+    icon: ImageVector,
+    contentDescription: String,
+    onClick: () -> Unit,
+) {
+    val containerColor = if (selected) {
+        LegadoTheme.colorScheme.secondaryContainer
+    } else {
+        LegadoTheme.colorScheme.surfaceContainerLow
+    }
+    val contentColor = if (selected) {
+        LegadoTheme.colorScheme.onSecondaryContainer
+    } else {
+        LegadoTheme.colorScheme.onSurfaceVariant
+    }
+    Box(
+        modifier = Modifier
+            .size(32.dp)
+            .clip(RoundedCornerShape(10.dp))
+            .background(containerColor)
+            .clickable(onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        Icon(
+            imageVector = icon,
+            contentDescription = contentDescription,
+            tint = contentColor,
+            modifier = Modifier.size(16.dp),
+        )
+    }
+}
+
+@Composable
+private fun ReaderBookTocPage(
+    state: TocUiState,
+    onIntent: (TocIntent) -> Unit,
+    onChapterClick: (Int) -> Unit,
+    onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
+    onEditLocalTocRule: (String?) -> Unit,
+) {
+    val action = state.action
+    val listState = rememberLazyListState()
+    val focusRequester = remember { FocusRequester() }
+    var menuExpanded by rememberSaveable { mutableStateOf(false) }
+    var fabExpanded by rememberSaveable { mutableStateOf(false) }
+    var hasAutoScrolled by rememberSaveable { mutableStateOf(false) }
+    val selected = action.selectedIds.isNotEmpty()
+    val selectAllText = stringResource(R.string.select_all)
+    val invertText = stringResource(R.string.invert_selection)
+    val bookmarkText = stringResource(R.string.bookmark_add)
+    val downloadSelectedText = stringResource(
+        R.string.download_selected_count,
+        action.selectedIds.size,
+    )
+    val cancelText = stringResource(R.string.cancel)
+    val fabItems = remember(
+        selected,
+        action.selectedIds,
+        selectAllText,
+        invertText,
+        bookmarkText,
+        downloadSelectedText,
+        cancelText,
+    ) {
+        listOf(
+            FabMenuItem(Icons.Default.SelectAll, selectAllText) {
+                onIntent(TocIntent.SelectAll)
+            },
+            FabMenuItem(Icons.Default.SwapVert, invertText) {
+                onIntent(TocIntent.InvertSelection)
+            },
+            FabMenuItem(Icons.Default.BookmarkAdd, bookmarkText) {
+                onIntent(TocIntent.AddBookmarksForSelected)
+            },
+            FabMenuItem(Icons.Default.Download, downloadSelectedText) {
+                onIntent(TocIntent.DownloadSelected)
+            },
+            FabMenuItem(Icons.Default.Clear, cancelText) {
+                onIntent(TocIntent.ClearSelection)
+            },
+        )
+    }
+    LaunchedEffect(selected) {
+        fabExpanded = false
+    }
+    LaunchedEffect(action.items) {
+        if (!hasAutoScrolled && action.items.isNotEmpty()) {
+            val currentIndex = action.items.indexOfFirst { it.isDur }
+            if (currentIndex >= 0) listState.scrollToItem(currentIndex)
+            hasAutoScrolled = true
+        }
+    }
+
+    Column(modifier = Modifier.fillMaxSize()) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(vertical = 8.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            CompactSearchField(
+                query = action.searchKey,
+                onQueryChange = { onIntent(TocIntent.SetSearchQuery(it)) },
+                placeholder = stringResource(R.string.search_chapters),
+                modifier = Modifier.weight(1f),
+            )
+            CompactToolIconBox(
+                selected = action.showWordCount,
+                icon = Icons.Default.Numbers,
+                contentDescription = stringResource(R.string.show_word_count),
+                onClick = { onIntent(TocIntent.ToggleShowWordCount) },
+            )
+            CompactToolIconBox(
+                selected = state.isReverse,
+                icon = Icons.Default.SwapVert,
+                contentDescription = stringResource(R.string.reverse_toc),
+                onClick = { onIntent(TocIntent.ReverseToc) },
+            )
+            CompactToolIconBox(
+                selected = action.useReplace,
+                icon = Icons.Default.Shuffle,
+                contentDescription = stringResource(R.string.use_replace_rule),
+                onClick = { onIntent(TocIntent.ToggleUseReplace) },
+            )
+            Box {
+                CompactToolIconBox(
+                    selected = menuExpanded,
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu),
+                    onClick = { menuExpanded = !menuExpanded },
+                )
+                RoundDropdownMenu(
+                    expanded = menuExpanded,
+                    onDismissRequest = { menuExpanded = false },
+                ) {
+                    ReaderBookTocMenu(
+                        state = state,
+                        onIntent = onIntent,
+                        onDismiss = { menuExpanded = false },
+                        onOpenReplaceRule = onOpenReplaceRule,
+                        onEditLocalTocRule = onEditLocalTocRule,
+                    )
+                }
+            }
+        }
+        Box(modifier = Modifier.weight(1f)) {
+            ReaderSheetChapterList(
+                state = state,
+                listState = listState,
+                onIntent = onIntent,
+                onChapterClick = onChapterClick,
+            )
+            AppFloatingActionButtonMenu(
+                expanded = fabExpanded,
+                onExpandedChange = { fabExpanded = it },
+                items = fabItems,
+                visible = selected,
+                focusRequester = focusRequester,
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .offset(x = 8.dp, y = 8.dp),
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ReaderSheetChapterList(
+    state: TocUiState,
+    listState: androidx.compose.foundation.lazy.LazyListState,
+    onIntent: (TocIntent) -> Unit,
+    onChapterClick: (Int) -> Unit,
+) {
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(bottom = 88.dp),
+        verticalArrangement = Arrangement.spacedBy(6.dp),
+    ) {
+        items(
+            items = state.action.items,
+            key = { item -> if (item.isVolume) "sheet-volume-${item.id}" else "sheet-chapter-${item.id}" },
+            contentType = { item -> if (item.isVolume) "volume" else "chapter" },
+        ) { item ->
+            if (item.isVolume) {
+                ReaderSheetVolumeItem(
+                    item = item,
+                    collapsed = item.id in state.collapsedVolumes,
+                    onClick = { onIntent(TocIntent.ToggleVolume(item.id)) },
+                    modifier = Modifier.animateItem(),
+                )
+            } else {
+                ReaderSheetChapterItem(
+                    item = item,
+                    showWordCount = state.action.showWordCount,
+                    selectionMode = state.action.selectedIds.isNotEmpty(),
+                    onClick = {
+                        if (state.action.selectedIds.isNotEmpty()) {
+                            onIntent(TocIntent.ToggleSelection(item.id))
+                        } else {
+                            onChapterClick(item.id)
+                        }
+                    },
+                    onLongClick = { onIntent(TocIntent.ToggleSelection(item.id)) },
+                    onDownloadClick = { onIntent(TocIntent.DownloadChapter(item.id)) },
+                    modifier = Modifier.animateItem(),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetVolumeItem(
+    item: TocItemUi,
+    collapsed: Boolean,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    NormalCard(
+        onClick = onClick,
+        cornerRadius = 12.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(start = (item.tocLevel.coerceIn(0, 6) * 10).dp),
+    ) {
+        Row(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Icon(
+                imageVector = Icons.Default.ExpandMore,
+                contentDescription = null,
+                modifier = Modifier
+                    .size(18.dp)
+                    .rotate(if (collapsed) -90f else 0f),
+                tint = LegadoTheme.colorScheme.primary,
+            )
+            AppText(
+                text = item.title,
+                style = LegadoTheme.typography.labelLargeEmphasized,
+                modifier = Modifier.padding(start = 8.dp),
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetChapterItem(
+    item: TocItemUi,
+    showWordCount: Boolean,
+    selectionMode: Boolean,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+    onDownloadClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val containerColor = when {
+        item.isSelected -> LegadoTheme.colorScheme.primaryContainer
+        item.isDur -> LegadoTheme.colorScheme.secondaryContainer
+        else -> LegadoTheme.colorScheme.surfaceContainerLow
+    }
+    val contentColor = when {
+        item.isSelected -> LegadoTheme.colorScheme.onPrimaryContainer
+        item.isDur -> LegadoTheme.colorScheme.onSecondaryContainer
+        else -> LegadoTheme.colorScheme.onSurface
+    }
+    NormalCard(
+        onClick = onClick,
+        onLongClick = onLongClick,
+        cornerRadius = 12.dp,
+        containerColor = containerColor,
+        contentColor = contentColor,
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(start = (item.tocLevel.coerceIn(0, 6) * 10).dp),
+    ) {
+        Row(
+            modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Column(modifier = Modifier.weight(1f)) {
+                AppText(
+                    modifier = Modifier.padding(vertical = 8.dp),
+                    text = item.title,
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                    color = contentColor,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                if (!item.tag.isNullOrBlank()) {
+                    AppText(
+                        text = item.tag,
+                        style = LegadoTheme.typography.labelSmall,
+                        color = contentColor.copy(alpha = 0.72f),
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+            ReaderSheetChapterStatus(
+                item = item,
+                showWordCount = showWordCount,
+                enabled = !selectionMode,
+                onDownloadClick = onDownloadClick,
+            )
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetChapterStatus(
+    item: TocItemUi,
+    showWordCount: Boolean,
+    enabled: Boolean,
+    onDownloadClick: () -> Unit,
+) {
+    val showCount = showWordCount &&
+        !item.wordCount.isNullOrBlank() &&
+        (item.downloadState == DownloadState.LOCAL || item.downloadState == DownloadState.SUCCESS)
+    when {
+        showCount -> NormalCard(
+            cornerRadius = 8.dp,
+            containerColor = if (item.isDur) {
+                LegadoTheme.colorScheme.primaryContainer
+            } else {
+                LegadoTheme.colorScheme.surfaceContainer
+            },
+            modifier = Modifier.padding(horizontal = 8.dp),
+        ) {
+            AppText(
+                text = item.wordCount,
+                style = LegadoTheme.typography.labelSmallEmphasized.copy(fontSize = 8.sp),
+                color = if (item.isDur) {
+                    LegadoTheme.colorScheme.onPrimaryContainer
+                } else {
+                    LegadoTheme.colorScheme.onSurfaceVariant
+                },
+                modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+            )
+        }
+        item.isDur -> ReaderSheetStatusIcon(Icons.Default.LocationOn)
+        item.downloadState == DownloadState.DOWNLOADING -> AppContainedLoadingIndicator(
+            modifier = Modifier
+                .padding(horizontal = 8.dp)
+                .size(16.dp),
+        )
+        item.downloadState == DownloadState.SUCCESS -> ReaderSheetStatusIcon(
+            imageVector = Icons.Default.CheckCircle,
+        )
+        item.downloadState == DownloadState.ERROR -> IconButton(
+            onClick = onDownloadClick,
+            enabled = enabled,
+            modifier = Modifier.size(32.dp),
+        ) {
+            Icon(
+                imageVector = Icons.Default.Refresh,
+                contentDescription = stringResource(R.string.a11y_retry_chapter, item.title),
+                tint = LegadoTheme.colorScheme.error,
+                modifier = Modifier.size(16.dp),
+            )
+        }
+        item.downloadState == DownloadState.NONE -> IconButton(
+            onClick = onDownloadClick,
+            enabled = enabled,
+            modifier = Modifier.size(32.dp),
+        ) {
+            Icon(
+                imageVector = Icons.Outlined.DownloadForOffline,
+                contentDescription = stringResource(R.string.download_chapter, item.title),
+                tint = LegadoTheme.colorScheme.outline,
+                modifier = Modifier.size(16.dp),
+            )
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetStatusIcon(
+    imageVector: ImageVector,
+) {
+    Icon(
+        imageVector = imageVector,
+        contentDescription = null,
+        tint = LegadoTheme.colorScheme.secondary,
+        modifier = Modifier
+            .padding(horizontal = 8.dp)
+            .size(16.dp),
+    )
+}
+
+@Composable
+private fun ReaderBookTocMenu(
+    state: TocUiState,
+    onIntent: (TocIntent) -> Unit,
+    onDismiss: () -> Unit,
+    onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
+    onEditLocalTocRule: (String?) -> Unit,
+) {
+    val book = state.book
+    fun dispatch(intent: TocIntent) {
+        onDismiss()
+        onIntent(intent)
+    }
+    RoundDropdownMenuItem(
+        text = stringResource(R.string.download_all),
+        onClick = { dispatch(TocIntent.DownloadAll) },
+    )
+    if (state.action.items.any { it.isVolume }) {
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.expand_volume),
+            onClick = { dispatch(TocIntent.ExpandAllVolumes) },
+        )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.coll_volume),
+            onClick = { dispatch(TocIntent.CollapseAllVolumes) },
+        )
+    }
+    RoundDropdownMenuItem(
+        text = stringResource(R.string.replace_rule_title),
+        onClick = {
+            onDismiss()
+            onOpenReplaceRule(null)
+        },
+    )
+    RoundDropdownMenuItem(
+        text = stringResource(R.string.add_replace_rule),
+        onClick = {
+            onDismiss()
+            val scopes = listOfNotNull(book?.name, book?.origin).joinToString(";")
+            onOpenReplaceRule(
+                ReplaceEditRoute(
+                    id = -1,
+                    pattern = "",
+                    scope = scopes,
+                    isScopeTitle = true,
+                    isScopeContent = false,
+                )
+            )
+        },
+    )
+    if (book?.isLocal == true) {
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.local_book_toc_rule),
+            onClick = {
+                onDismiss()
+                onEditLocalTocRule(book.tocUrl)
+            },
+        )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.split_long_chapters),
+            isSelected = state.isSplitLongChapter,
+            onClick = { dispatch(TocIntent.ToggleSplitLongChapter) },
+        )
+    }
+}
+
+@Composable
+private fun ReaderBookBookmarksPage(
+    state: TocUiState,
+    onIntent: (TocIntent) -> Unit,
+    onBookmarkNavigate: (Int, Int) -> Unit,
+    onEditBookmark: (Bookmark) -> Unit,
+    onExportBookmarks: (Boolean, String) -> Unit,
+) {
+    var menuExpanded by rememberSaveable { mutableStateOf(false) }
+    val date = remember { SimpleDateFormat("yyyyMMdd_HHmm", Locale.getDefault()).format(Date()) }
+    Column(modifier = Modifier.fillMaxSize()) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(vertical = 8.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            CompactSearchField(
+                query = state.action.searchKey,
+                onQueryChange = { onIntent(TocIntent.SetSearchQuery(it)) },
+                placeholder = stringResource(R.string.search),
+                modifier = Modifier.weight(1f),
+            )
+            Box {
+                CompactToolIconBox(
+                    selected = menuExpanded,
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu),
+                    onClick = { menuExpanded = !menuExpanded },
+                )
+                RoundDropdownMenu(
+                    expanded = menuExpanded,
+                    onDismissRequest = { menuExpanded = false },
+                ) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.export_bookmarks_json),
+                        onClick = {
+                            menuExpanded = false
+                            onExportBookmarks(false, "${state.book?.name ?: "bookmark"}_$date.json")
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.export_bookmarks_markdown),
+                        onClick = {
+                            menuExpanded = false
+                            onExportBookmarks(true, "${state.book?.name ?: "bookmark"}_$date.md")
+                        },
+                    )
+                }
+            }
+        }
+        ReaderSheetBookmarkList(
+            bookmarks = state.bookmarks,
+            currentChapterIndex = state.book?.durChapterIndex,
+            onBookmarkNavigate = onBookmarkNavigate,
+            onEditBookmark = onEditBookmark,
+        )
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ReaderSheetBookmarkList(
+    bookmarks: List<TocBookmarkItemUi>,
+    currentChapterIndex: Int?,
+    onBookmarkNavigate: (Int, Int) -> Unit,
+    onEditBookmark: (Bookmark) -> Unit,
+) {
+    val listState = rememberLazyListState()
+
+    LaunchedEffect(bookmarks, currentChapterIndex) {
+        if (bookmarks.isNotEmpty() && currentChapterIndex != null) {
+            val currentPosition = bookmarks
+                .indexOfLast { it.chapterIndex <= currentChapterIndex }
+                .coerceAtLeast(0)
+            listState.scrollToItem(currentPosition)
+        }
+    }
+
+    if (bookmarks.isEmpty()) {
+        Box(
+            modifier = Modifier.fillMaxSize(),
+            contentAlignment = Alignment.Center,
+        ) {
+            EmptyMessage(message = stringResource(R.string.no_bookmark))
+        }
+        return
+    }
+
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(bottom = 24.dp),
+        verticalArrangement = Arrangement.spacedBy(6.dp),
+    ) {
+        items(
+            items = bookmarks,
+            key = { it.id },
+            contentType = { "bookmark" },
+        ) { bookmark ->
+            ReaderSheetBookmarkItem(
+                item = bookmark,
+                onClick = { onEditBookmark(bookmark.raw) },
+                onLongClick = {
+                    onBookmarkNavigate(bookmark.chapterIndex, bookmark.chapterPos)
+                },
+                modifier = Modifier.animateItem(),
+            )
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetBookmarkItem(
+    item: TocBookmarkItemUi,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val containerColor = if (item.isDur) {
+        LegadoTheme.colorScheme.secondaryContainer
+    } else {
+        LegadoTheme.colorScheme.surfaceContainerLow
+    }
+    val contentColor = if (item.isDur) {
+        LegadoTheme.colorScheme.onSecondaryContainer
+    } else {
+        LegadoTheme.colorScheme.onSurface
+    }
+
+    NormalCard(
+        onClick = onClick,
+        onLongClick = onLongClick,
+        cornerRadius = 12.dp,
+        containerColor = containerColor,
+        contentColor = contentColor,
+        modifier = modifier.fillMaxWidth(),
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                AppText(
+                    text = item.chapterName,
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                    color = contentColor,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.weight(1f),
+                )
+                if (item.isDur) {
+                    Icon(
+                        imageVector = Icons.Default.LocationOn,
+                        contentDescription = null,
+                        tint = contentColor,
+                        modifier = Modifier.size(16.dp),
+                    )
+                }
+            }
+            if (item.raw.bookText.isNotBlank()) {
+                AppText(
+                    text = item.raw.bookText,
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = contentColor.copy(alpha = 0.78f),
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+            if (item.content.isNotBlank()) {
+                AppText(
+                    text = item.content,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.primary,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+        }
+    }
+}

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt

@@ -18,6 +18,8 @@ class TocActivity : BaseComposeActivity() {
         val context = LocalContext.current
 
         TocRouteScreen(
+            bookUrl = intent.getStringExtra("bookUrl"),
+            initialPage = intent.getIntExtra("initialPage", 0),
             onBackClick = { finish() },
             onChapterClick = { index ->
                 val data = Intent().apply {

+ 37 - 29
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -1,6 +1,5 @@
 package io.legado.app.ui.book.toc
 
-import android.app.Activity
 import android.content.Intent
 import android.net.Uri
 import android.widget.Toast
@@ -133,6 +132,8 @@ import java.util.Locale
 @Composable
 fun TocRouteScreen(
     viewModel: TocViewModel = koinViewModel(),
+    bookUrl: String? = null,
+    initialPage: Int = 0,
     onBackClick: () -> Unit,
     onChapterClick: (Int) -> Unit,
     onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
@@ -140,6 +141,24 @@ fun TocRouteScreen(
 ) {
     val state by viewModel.screenState.collectAsStateWithLifecycle()
     val context = LocalContext.current
+    var pendingExportMarkdown by remember { mutableStateOf(false) }
+    val exportLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.CreateDocument("*/*")
+    ) { uri: Uri? ->
+        uri?.let { viewModel.onIntent(TocIntent.ExportBookmarks(it, pendingExportMarkdown)) }
+    }
+    val tocRegexLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.StartActivityForResult()
+    ) { result ->
+        if (result.resultCode == android.app.Activity.RESULT_OK) {
+            viewModel.onIntent(
+                TocIntent.SaveTocRegex(result.data?.getStringExtra("tocRegex").orEmpty())
+            )
+        }
+    }
+    LaunchedEffect(bookUrl) {
+        bookUrl?.let { viewModel.onIntent(TocIntent.LoadBook(it)) }
+    }
     LaunchedEffect(viewModel) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
@@ -155,6 +174,16 @@ fun TocRouteScreen(
         onChapterClick = onChapterClick,
         onOpenReplaceRule = onOpenReplaceRule,
         onBookmarkClick = onBookmarkClick,
+        onEditLocalTocRule = { regex ->
+            tocRegexLauncher.launch(
+                Intent(context, TxtTocRuleActivity::class.java).putExtra("tocRegex", regex)
+            )
+        },
+        onExportBookmarks = { isMarkdown, fileName ->
+            pendingExportMarkdown = isMarkdown
+            exportLauncher.launch(fileName)
+        },
+        initialPage = initialPage,
     )
 }
 
@@ -167,14 +196,15 @@ fun TocScreen(
     onChapterClick: (Int) -> Unit,
     onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
     onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
+    onEditLocalTocRule: (String?) -> Unit,
+    onExportBookmarks: (isMarkdown: Boolean, fileName: String) -> Unit,
+    initialPage: Int = 0,
 ) {
-
-    val context = LocalContext.current
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val book = uiState.book
     val state = uiState.action
 
-    val pagerState = rememberPagerState { 2 }
+    val pagerState = rememberPagerState(initialPage = initialPage.coerceIn(0, 1)) { 2 }
     val scope = rememberCoroutineScope()
 
     val listState = rememberLazyListState()
@@ -323,24 +353,6 @@ fun TocScreen(
         )
     }
 
-    val exportLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.CreateDocument("*/*")
-    ) { uri: Uri? ->
-        uri?.let {
-            val isActuallyMd = it.toString().endsWith(".md", ignoreCase = true)
-            onIntent(TocIntent.ExportBookmarks(it, isActuallyMd))
-        }
-    }
-
-    val tocRegexLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.StartActivityForResult()
-    ) { result ->
-        if (result.resultCode == Activity.RESULT_OK) {
-            val newRegex = result.data?.getStringExtra("tocRegex")
-            onIntent(TocIntent.SaveTocRegex(newRegex ?: ""))
-        }
-    }
-
     var hasAutoScrolled by rememberSaveable { mutableStateOf(false) }
 
     LaunchedEffect(state.items, book) {
@@ -463,11 +475,7 @@ fun TocScreen(
                                 RoundDropdownMenuItem(
                                     text = stringResource(R.string.local_book_toc_rule),
                                     onClick = {
-                                        val intent =
-                                            Intent(context, TxtTocRuleActivity::class.java).apply {
-                                                putExtra("tocRegex", book?.tocUrl)
-                                            }
-                                        tocRegexLauncher.launch(intent)
+                                        onEditLocalTocRule(book?.tocUrl)
                                         dismiss()
                                     }
                                 )
@@ -492,7 +500,7 @@ fun TocScreen(
                                     ).format(Date())
                                     val initialName =
                                         "${book?.name ?: bookmarkDefaultFileName}_$dateFormat.json"
-                                    exportLauncher.launch(initialName)
+                                    onExportBookmarks(false, initialName)
                                     dismiss()
                                 }
                             )
@@ -505,7 +513,7 @@ fun TocScreen(
                                     ).format(Date())
                                     val initialName =
                                         "${book?.name ?: bookmarkDefaultFileName}_$dateFormat.md"
-                                    exportLauncher.launch(initialName)
+                                    onExportBookmarks(true, initialName)
                                     dismiss()
                                 }
                             )

+ 11 - 1
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -105,9 +105,11 @@ data class TocUiState(
     val collapsedVolumes: ImmutableSet<Int> = persistentSetOf(),
     val bookmarks: ImmutableList<TocBookmarkItemUi> = persistentListOf(),
     val isSplitLongChapter: Boolean = false,
+    val isReverse: Boolean = false,
 )
 
 sealed interface TocIntent {
+    data class LoadBook(val bookUrl: String) : TocIntent
     data class SetSearchMode(val enabled: Boolean) : TocIntent
     data class SetSearchQuery(val query: String) : TocIntent
     data class ToggleVolume(val id: Int) : TocIntent
@@ -214,7 +216,7 @@ class TocViewModel(
     initialState = TocActionState()
 ) {
 
-    private val bookUrlFlow = savedStateHandle.getStateFlow<String?>("bookUrl", null)
+    private val bookUrlFlow = MutableStateFlow(savedStateHandle.get<String>("bookUrl"))
     val bookState = bookUrlFlow
         .filterNotNull()
         .flatMapLatest { url ->
@@ -309,6 +311,7 @@ class TocViewModel(
                 collapsedVolumes = collapsed.toImmutableSet(),
                 bookmarks = bookmarks.toImmutableList(),
                 isSplitLongChapter = book?.getSplitLongChapter() ?: false,
+                isReverse = book?.getReverseToc() ?: false,
             )
         }.stateIn(
             scope = viewModelScope,
@@ -511,6 +514,13 @@ class TocViewModel(
 
     fun onIntent(intent: TocIntent) {
         when (intent) {
+            is TocIntent.LoadBook -> {
+                if (bookUrlFlow.value != intent.bookUrl) {
+                    clearSelection()
+                    _collapsedVolumes.value = emptySet()
+                    bookUrlFlow.value = intent.bookUrl
+                }
+            }
             is TocIntent.SetSearchMode -> setSearchMode(intent.enabled)
             is TocIntent.SetSearchQuery -> setSearchKey(intent.query)
             is TocIntent.ToggleVolume -> toggleVolume(intent.id)

+ 23 - 10
app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.widget.components.tabRow
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -32,6 +33,7 @@ fun CardTabRow(
     selectedTabIndex: Int,
     onTabSelected: (Int) -> Unit,
     modifier: Modifier = Modifier,
+    tabEndContent: (@Composable (Int) -> Unit)? = null,
 ) {
     var lastSelectedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
     val isSelectionChanged = selectedTabIndex != lastSelectedTabIndex
@@ -74,18 +76,29 @@ fun CardTabRow(
                 contentColor = contentColor,
                 cornerRadius = 12.dp
             ) {
-                AppText(
-                    text = title,
-                    style = LegadoTheme.typography.labelMediumEmphasized,
-                    fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
-                    color = contentColor,
-                    maxLines = 1,
+                Box(
                     modifier = Modifier
                         .fillMaxWidth()
-                        .padding(vertical = 10.dp)
-                        .align(Alignment.CenterHorizontally),
-                    textAlign = TextAlign.Center,
-                )
+                        .padding(horizontal = 8.dp, vertical = 8.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    AppText(
+                        text = title,
+                        style = LegadoTheme.typography.labelMediumEmphasized,
+                        fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+                        color = contentColor,
+                        maxLines = 1,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(horizontal = if (tabEndContent == null) 0.dp else 20.dp),
+                        textAlign = TextAlign.Center,
+                    )
+                    if (tabEndContent != null) {
+                        Box(modifier = Modifier.align(Alignment.CenterEnd)) {
+                            tabEndContent(index)
+                        }
+                    }
+                }
             }
         }
     }