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

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