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

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") @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
fun getBook(bookUrl: String): Book? 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") @Query("SELECT * FROM books WHERE name = :name and author = :author")
fun getBook(name: String, author: String): Book? fun getBook(name: String, author: String): Book?
@@ -189,7 +189,7 @@ object BookHelp {
if (book.isOnLineTxt && AppConfig.tocCountWords) { if (book.isOnLineTxt && AppConfig.tocCountWords) {
val wordCount = StringUtils.wordCountFormat(content.length) val wordCount = StringUtils.wordCountFormat(content.length)
bookChapter.wordCount = wordCount 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() val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _downloadingIndicesFlow = 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 = 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() { private fun updateSummary() {
_downloadSummaryFlow.value = downloadSummary _downloadSummaryFlow.value = downloadSummary
@@ -4,5 +4,6 @@ enum class DownloadState {
NONE, NONE,
DOWNLOADING, DOWNLOADING,
SUCCESS, SUCCESS,
ERROR ERROR,
LOCAL
} }
@@ -13,17 +13,12 @@ import io.legado.app.ui.replace.ReplaceRuleActivity
*/ */
class TocActivity : BaseComposeActivity() { class TocActivity : BaseComposeActivity() {
private val bookUrl: String? by lazy {
intent.getStringExtra("bookUrl")
}
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
TocScreen( TocScreen(
onBackClick = { finish() }, onBackClick = { finish() },
bookUrl = bookUrl,
onChapterClick = { index -> onChapterClick = { index ->
val data = Intent().apply { val data = Intent().apply {
putExtra("index", index) putExtra("index", index)
@@ -35,7 +30,14 @@ class TocActivity : BaseComposeActivity() {
val intent = ReplaceRuleActivity.startIntent(context, editRoute) val intent = ReplaceRuleActivity.startIntent(context, editRoute)
context.startActivity(intent) 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.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box 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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Refresh
import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.VerticalAlignBottom
import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material.icons.outlined.DownloadForOffline
import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButtonMenu import androidx.compose.material3.FloatingActionButtonMenu
import androidx.compose.material3.FloatingActionButtonMenuItem import androidx.compose.material3.FloatingActionButtonMenuItem
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.PrimaryScrollableTabRow
@@ -82,6 +81,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.Bookmark
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
import io.legado.app.ui.replace.ReplaceEditRoute import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.widget.CollapsibleHeader import io.legado.app.ui.widget.CollapsibleHeader
import io.legado.app.ui.widget.components.ActionItem 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.SelectionBottomBar
import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
import io.legado.app.ui.widget.components.bookmark.BookmarkItem 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.PillDivider
import io.legado.app.ui.widget.components.divider.PillHeaderDivider 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.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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -119,12 +120,12 @@ import java.util.Locale
@Composable @Composable
fun TocScreen( fun TocScreen(
viewModel: TocViewModel = koinViewModel(), viewModel: TocViewModel = koinViewModel(),
bookUrl: String?,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onChapterClick: (Int) -> Unit, onChapterClick: (Int) -> Unit,
onOpenReplaceRule: (ReplaceEditRoute?) -> Unit, onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val book by viewModel.bookState.collectAsStateWithLifecycle() val book by viewModel.bookState.collectAsStateWithLifecycle()
@@ -132,11 +133,17 @@ fun TocScreen(
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val offset by remember {
derivedStateOf {
listState.layoutInfo.viewportEndOffset / 4
}
}
val isSelectionMode = state.selectedIds.isNotEmpty() 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 showVolumeMenu by remember { mutableStateOf(false) }
var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
@@ -156,60 +163,32 @@ fun TocScreen(
} }
val isOnTocPage = pagerState.currentPage == 0 val isOnTocPage = pagerState.currentPage == 0
var isFabVisible by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(listState, isOnTocPage, isSelectionMode) { val fabItems = remember(state.items) {
listOf(
if (!isOnTocPage || isSelectionMode) { FabAction(Icons.Default.LocationOn, "定位至当前阅读") {
isFabVisible = false scope.launch {
return@LaunchedEffect val target = state.items.indexOfFirst { it.isDur }
} if (target != -1) {
listState.animateScrollToItem(
var previousIndex = listState.firstVisibleItemIndex index = target,
var previousOffset = listState.firstVisibleItemScrollOffset scrollOffset = -offset
)
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 FabAction(Icons.Default.VerticalAlignTop, "移至顶部") {
previousOffset = offset 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) { val selectionSecondaryActions = remember(state.selectedIds) {
listOf( listOf(
ActionItem( ActionItem(
@@ -248,14 +227,50 @@ fun TocScreen(
} }
} }
LaunchedEffect(isFabVisible) { var hasAutoScrolled by rememberSaveable { mutableStateOf(false) }
if (!isFabVisible && fabMenuExpanded) {
fabMenuExpanded = 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) { var isFabVisible by rememberSaveable { mutableStateOf(true) }
bookUrl?.let { viewModel.initBook(it) }
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) { BackHandler(enabled = isSelectionMode) {
@@ -425,51 +440,37 @@ fun TocScreen(
icon = Icons.AutoMirrored.Filled.FormatListBulleted, icon = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = "卷管理" contentDescription = "卷管理"
) )
DropdownMenu( RoundDropdownMenu(
expanded = showVolumeMenu, expanded = showVolumeMenu,
onDismissRequest = { showVolumeMenu = false } onDismissRequest = { showVolumeMenu = false }
) { ) {
DropdownMenuItem( RoundDropdownMenuItem(
text = { Text("展开所有卷") }, text = "展开所有卷",
onClick = { onClick = {
viewModel.expandAllVolumes(); showVolumeMenu = false viewModel.expandAllVolumes(); showVolumeMenu = false
} }
) )
DropdownMenuItem( RoundDropdownMenuItem(
text = { Text("收起所有卷") }, text = "收起所有卷",
onClick = { onClick = {
viewModel.collapseAllVolumes(); showVolumeMenu = false 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()) { if (volumeItems.isNotEmpty()) {
Text( PillHeaderDivider(title = "快速跳转")
"快速跳转",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
),
color = MaterialTheme.colorScheme.primary
)
volumeItems.forEach { uiItem -> volumeItems.forEach { uiItem ->
DropdownMenuItem( RoundDropdownMenuItem(
text = { text = uiItem.title,
Text(
uiItem.displayTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = { onClick = {
scope.launch { scope.launch {
val targetIndex = val targetIndex =
state.items.indexOfFirst { it.chapter.index == uiItem.chapter.index } state.items.indexOf(uiItem)
if (targetIndex != -1) { if (targetIndex != -1) {
listState.animateScrollToItem( listState.animateScrollToItem(
targetIndex index = targetIndex
) )
} }
} }
@@ -494,7 +495,7 @@ fun TocScreen(
ToggleFloatingActionButton( ToggleFloatingActionButton(
modifier = Modifier modifier = Modifier
.animateFloatingActionButton( .animateFloatingActionButton(
visible = isFabVisible, visible = shouldShowFab,
alignment = Alignment.BottomEnd, alignment = Alignment.BottomEnd,
) )
.focusRequester(focusRequester), .focusRequester(focusRequester),
@@ -562,8 +563,8 @@ fun TocScreen(
1 -> BookmarkListContent( 1 -> BookmarkListContent(
viewModel = viewModel, viewModel = viewModel,
onBookmarkClick = onBookmarkClick, onBookmarkLongClick = onBookmarkClick,
onBookmarkLongClick = { bookmark -> onBookmarkClick = { bookmark ->
editingBookmark = bookmark editingBookmark = bookmark
}, },
contentPadding = PaddingValues(bottom = if (isSelectionMode) 80.dp else 0.dp) contentPadding = PaddingValues(bottom = if (isSelectionMode) 80.dp else 0.dp)
@@ -599,7 +600,6 @@ fun ChapterListContent(
contentPadding: PaddingValues contentPadding: PaddingValues
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val book by viewModel.bookState.collectAsStateWithLifecycle()
val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle() val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle()
FastScrollLazyColumn( FastScrollLazyColumn(
@@ -607,124 +607,78 @@ fun ChapterListContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding contentPadding = contentPadding
) { ) {
state.items.forEach { uiItem ->
val chapter = uiItem.chapter
if (chapter.isVolume) { state.items.forEach { uiItem ->
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${chapter.index}") {
if (uiItem.isVolume) {
stickyHeader(key = "volume-${uiItem.id}") {
CollapsibleHeader( CollapsibleHeader(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
title = uiItem.displayTitle, title = uiItem.title,
isCollapsed = collapsedVolumes.contains(chapter.title), isCollapsed = collapsedVolumes.contains(uiItem.id),
onToggle = { viewModel.toggleVolume(chapter.title) } onToggle = { viewModel.toggleVolume(uiItem.id) }
) )
} }
} else { } 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( ChapterItem(
modifier = Modifier modifier = Modifier
.animateItem() .animateItem()
.fillMaxWidth(), .fillMaxWidth(),
title = uiItem.displayTitle, item = uiItem,
chapter = chapter,
isDur = book?.durChapterIndex == chapter.index,
isSelected = isSelected,
downloadState = downloadState,
showWordCount = viewModel.showWordCount, showWordCount = viewModel.showWordCount,
onClick = { onClick = {
if (state.selectedIds.isNotEmpty()) if (state.selectedIds.isNotEmpty())
viewModel.toggleSelection(chapter.index) viewModel.toggleSelection(uiItem.id)
else else
onChapterClick(chapter.index) onChapterClick(uiItem.id)
}, },
onLongClick = { 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 @Composable
fun ChapterItem( fun ChapterItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String, item: TocItemUi,
chapter: BookChapter,
isDur: Boolean,
isSelected: Boolean,
downloadState: DownloadState,
showWordCount: Boolean, showWordCount: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit,
onDownloadClick: () -> Unit
) { ) {
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = when { targetValue = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) item.isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
isDur -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) item.isDur -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
else -> Color.Transparent else -> Color.Transparent
}, label = "BgColor" }, label = "BgColor"
) )
val textColor by animateColorAsState( val textColor by animateColorAsState(
targetValue = when { targetValue = when {
isSelected -> MaterialTheme.colorScheme.onSurface item.isSelected -> MaterialTheme.colorScheme.onSurface
isDur -> MaterialTheme.colorScheme.primary item.isDur -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurface
}, label = "BgColor" }, label = "BgColor"
) )
val detailColor by animateColorAsState( val detailColor by animateColorAsState(
targetValue = when { targetValue = when {
isSelected -> MaterialTheme.colorScheme.onSurfaceVariant item.isSelected -> MaterialTheme.colorScheme.onSurfaceVariant
isDur -> MaterialTheme.colorScheme.primary item.isDur -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
}, label = "BgColor" }, label = "BgColor"
) )
@@ -740,12 +694,12 @@ fun ChapterItem(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (chapter.isVip && !chapter.isPay) { if (item.isVip && !item.isPay) {
Icon( Icon(
imageVector = Icons.Default.Lock, imageVector = Icons.Default.Lock,
contentDescription = null, contentDescription = null,
@@ -757,7 +711,7 @@ fun ChapterItem(
} }
Text( Text(
text = title, text = item.title,
style = MaterialTheme.typography.bodyMediumEmphasized.copy(fontWeight = FontWeight.Medium), style = MaterialTheme.typography.bodyMediumEmphasized.copy(fontWeight = FontWeight.Medium),
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
@@ -765,28 +719,40 @@ fun ChapterItem(
) )
} }
if (!chapter.tag.isNullOrEmpty()) { if (!item.tag.isNullOrEmpty()) {
Text( Text(
text = chapter.tag!!, text = item.tag,
style = MaterialTheme.typography.labelSmallEmphasized, style = MaterialTheme.typography.labelSmallEmphasized,
color = detailColor.copy(alpha = 0.8f), color = detailColor.copy(alpha = 0.8f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
if (showWordCount && !chapter.wordCount.isNullOrEmpty()) { if (item.downloadState != DownloadState.LOCAL) {
Text( Box(
text = "字数:${chapter.wordCount}", modifier = Modifier
style = MaterialTheme.typography.labelSmallEmphasized, .padding(start = 8.dp)
color = detailColor.copy(alpha = 0.6f), .wrapContentSize()
maxLines = 1, .clip(MaterialTheme.shapes.medium)
overflow = TextOverflow.Ellipsis .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 @Composable
fun BookmarkListContent( fun BookmarkListContent(
viewModel: TocViewModel, viewModel: TocViewModel,
onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, onBookmarkLongClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
onBookmarkLongClick: (Bookmark) -> Unit, onBookmarkClick: (Bookmark) -> Unit,
contentPadding: PaddingValues contentPadding: PaddingValues
) { ) {
val bookmarks by viewModel.bookmarks.collectAsStateWithLifecycle() val bookmarks by viewModel.bookmarkUiList.collectAsStateWithLifecycle()
val book by viewModel.bookState.collectAsStateWithLifecycle() val book by viewModel.bookState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -820,30 +786,31 @@ fun BookmarkListContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( EmptyMessageView(
text = "暂无书签", message = "暂无书签"
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge
) )
} }
} else { } else {
LazyColumn( FastScrollLazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding contentPadding = contentPadding
) { ) {
items( items(
items = bookmarks, items = bookmarks,
key = { it.time } key = { it.id }
) { bookmark -> ) { bookmark ->
BookmarkItem( BookmarkItem(
bookmark = bookmark, bookmark = bookmark.raw,
modifier = Modifier
.animateItem()
.fillMaxWidth(),
isDur = book?.durChapterIndex == bookmark.chapterIndex, isDur = book?.durChapterIndex == bookmark.chapterIndex,
onClick = { onClick = {
onBookmarkClick(bookmark.chapterIndex, bookmark.chapterPos) onBookmarkClick(bookmark.raw)
}, },
onLongClick = { onLongClick = {
onBookmarkLongClick(bookmark) onBookmarkLongClick(bookmark.chapterIndex, bookmark.chapterPos)
} }
) )
} }
@@ -854,12 +821,16 @@ fun BookmarkListContent(
@Composable @Composable
private fun StatusIcon( private fun StatusIcon(
isDur: Boolean, isDur: Boolean,
downloadState: DownloadState downloadState: DownloadState,
wordCount: String?,
showWordCount: Boolean
) { ) {
val targetState = when { val targetState = when {
isDur -> "DUR" isDur -> "DUR"
downloadState == DownloadState.DOWNLOADING -> "LOADING" 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" downloadState == DownloadState.ERROR -> "ERROR"
else -> "NONE" 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( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
@@ -911,7 +903,12 @@ private fun StatusIcon(
} }
else -> { 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.app.Application
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseRuleViewModel import io.legado.app.base.BaseRuleViewModel
import io.legado.app.data.appDb 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.data.entities.Bookmark
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor 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.help.bookmark.BookmarkExporter
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
@@ -25,124 +29,327 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class TocUiItem( @Immutable
val chapter: BookChapter, data class TocItemUi(
val displayTitle: String, override val id: Int,
override val id: Int = chapter.index 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> ) : 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( data class TocActionState(
override val items: List<TocUiItem> = emptyList(), override val items: List<TocItemUi> = emptyList(),
override val selectedIds: Set<Int> = emptySet(), override val selectedIds: Set<Int> = emptySet(),
override val searchKey: String = "", override val searchKey: String = "",
override val isSearch: Boolean = false, override val isSearch: Boolean = false,
override val isUploading: 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 = "" 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) @OptIn(ExperimentalCoroutinesApi::class)
class TocViewModel(application: Application) : class TocViewModel(
BaseRuleViewModel<TocUiItem, Pair<BookChapter, String>, Int, TocActionState>( application: Application,
application, savedStateHandle: SavedStateHandle
initialState = TocActionState() ) : BaseRuleViewModel<TocItemUi, TocDomainItem, Int, TocActionState>(
) { application,
initialState = TocActionState()
) {
private val _book = MutableStateFlow<Book?>(null) private val bookUrlFlow = savedStateHandle.getStateFlow<String?>("bookUrl", null)
val bookState = _book.asStateFlow() val bookState = bookUrlFlow
val isSplitLongChapter: Boolean .filterNotNull()
get() = _book.value?.getSplitLongChapter() ?: false .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() val collapsedVolumes = _collapsedVolumes.asStateFlow()
private val _cacheFileNames = MutableStateFlow<Set<String>>(emptySet()) val downloadSummary: StateFlow<String> =
val cacheFileNames = _cacheFileNames.asStateFlow() CacheBook.downloadSummaryFlow
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
""
)
private val _isReverse = MutableStateFlow(false) private val _cacheFileNames: StateFlow<Set<String>> = bookState.filterNotNull()
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()
.map { it.bookUrl } .map { it.bookUrl }
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { url -> .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) val bookmarkUiList: StateFlow<List<TocBookmarkItemUi>> =
private val processedChaptersFlow: Flow<List<Pair<BookChapter, String>>> = combine( combine(
dbChapterListFlow, bookState.filterNotNull(),
_isReverse, _searchKey
_book.filterNotNull(), ) { book, query ->
snapshotFlow { ReadConfig.tocUiUseReplace }, book to query
snapshotFlow { ReadConfig.tocCountWords } }
) { originalList, isReverse, book, useReplace, showWordCount -> .flatMapLatest { (book, query) ->
appDb.bookmarkDao
val processedList = if (isReverse) { .flowByBook(book.name, book.author)
originalList.fold(mutableListOf<MutableList<BookChapter>>()) { acc, chapter -> .map { list ->
if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter)) list
else acc.last().add(chapter) .asSequence()
acc .filter {
}.asReversed().flatMap { group -> query.isBlank() ||
if (group.firstOrNull()?.isVolume == true) { it.content.contains(query, ignoreCase = true)
listOf(group.first()) + group.drop(1).asReversed() }
} else group.asReversed() .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 { } else {
originalList originalChapters
} }
val replaceRules = if (useReplace && book.getUseReplaceRule()) { val replaceRules = if (config.useReplace && book.getUseReplaceRule()) {
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules() ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
} else emptyList() } else emptyList()
processedList.map { chapter -> if (book.isLocal) {
chapter to chapter.getDisplayTitle(replaceRules, true) 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 = val (downloadingPair, errorPair, cachedFiles) = downloadCtx
combine( val downloadingIndices =
_downloadingIndices, if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet()
_errorIndices, val errorIndices =
_cacheFileNames, if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
_downloadSummary
) { _, _, _, _ -> Unit }
override val rawDataFlow = processedChapters.map { chapter ->
combine(processedChaptersFlow, _collapsedVolumes, downloadStateTrigger) { list, _, _ -> val downloadState = when {
list 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 useReplace get() = ReadConfig.tocUiUseReplace
val showWordCount get() = ReadConfig.tocCountWords 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() { fun toggleUseReplace() {
ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace
} }
@@ -151,208 +358,9 @@ class TocViewModel(application: Application) :
ReadConfig.tocCountWords = !ReadConfig.tocCountWords ReadConfig.tocCountWords = !ReadConfig.tocCountWords
} }
override fun filterData( fun toggleVolume(volumeIndex: Int) {
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) {
_collapsedVolumes.update { current -> _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 { fun collapseAllVolumes() = execute {
val bookUrl = _book.value?.bookUrl ?: return@execute val bookUrl = bookState.value?.bookUrl ?: return@execute
val volumes = appDb.bookChapterDao.getChapterList(bookUrl) val volumes =
.filter { it.isVolume } appDb.bookChapterDao.getChapterList(bookUrl).filter { it.isVolume }.map { it.index }
.map { it.title } .toSet()
.toSet()
_collapsedVolumes.value = volumes _collapsedVolumes.value = volumes
} }
@@ -382,50 +389,82 @@ class TocViewModel(application: Application) :
setSelection(emptySet()) setSelection(emptySet())
} }
fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) { fun selectFromLast() {
viewModelScope.launch { val currentItems = uiState.value.items
try { val maxSelectedId = _selectedIds.value.maxOrNull() ?: return
val book = _book.value ?: return@launch val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId }
val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author) if (maxIndex == -1) return
setSelection(_selectedIds.value + currentItems.drop(maxIndex + 1).map { it.id })
}
if (bookmarks.isEmpty()) { fun saveTocRegex(newRegex: String) {
context.toastOnUi("没有可导出的书签") val book = bookState.value ?: return
return@launch book.tocUrl = newRegex
} upBookTocRule(book) { error ->
if (error != null) context.toastOnUi("更新目录规则失败: ${error.localizedMessage}")
BookmarkExporter.exportToUri( else {
context = getApplication(), context.toastOnUi("目录规则已更新")
fileUri = fileUri, if (ReadBook.book?.bookUrl == book.bookUrl) ReadBook.upMsg(null)
bookmarks = bookmarks,
isMd = isMd,
bookName = book.name,
author = book.author
)
context.toastOnUi("保存成功")
} catch (e: Exception) {
context.toastOnUi("保存失败: ${e.message}")
} }
} }
} }
fun updateBookmark(bookmark: Bookmark) { fun toggleSplitLongChapter() {
viewModelScope.launch(Dispatchers.IO) { val book = bookState.value ?: return
appDb.bookmarkDao.insert(bookmark) 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) { private fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) { _isUploading.value = true
appDb.bookmarkDao.delete(bookmark) 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() { fun downloadSelected() {
val book = _book.value ?: return val book = bookState.value ?: return
val indices = uiState.value.selectedIds.toList() val indices = uiState.value.selectedIds.toList()
if (indices.isEmpty()) return if (indices.isEmpty()) return
CacheBook.start(getApplication(), book, indices) CacheBook.start(getApplication(), book, indices)
@@ -433,24 +472,16 @@ class TocViewModel(application: Application) :
clearSelection() clearSelection()
} }
/**
* 下载单个章节
*/
fun downloadChapter(index: Int) { fun downloadChapter(index: Int) {
val book = _book.value ?: return val book = bookState.value ?: return
CacheBook.start(getApplication(), book, listOf(index)) CacheBook.start(getApplication(), book, listOf(index))
getApplication<Application>().toastOnUi("开始下载章节") getApplication<Application>().toastOnUi("开始下载章节")
} }
/**
* 下载所有章节
*/
fun downloadAll() { fun downloadAll() {
val book = _book.value ?: return val book = bookState.value ?: return
val cachedFiles = _cacheFileNames.value
val targetIndices = uiState.value.items val targetIndices = uiState.value.items
.filter { !it.chapter.isVolume && it.chapter.getFileName() !in cachedFiles } .filter { !it.isVolume && it.downloadState != DownloadState.SUCCESS }
.map { it.id } .map { it.id }
if (targetIndices.isEmpty()) { if (targetIndices.isEmpty()) {
@@ -462,14 +493,17 @@ class TocViewModel(application: Application) :
getApplication<Application>().toastOnUi("开始下载剩余 ${targetIndices.size} 个章节") getApplication<Application>().toastOnUi("开始下载剩余 ${targetIndices.size} 个章节")
} }
fun selectFromLast() { private fun List<BookChapter>.groupAndReverseVolumes(): List<BookChapter> {
val currentItems = uiState.value.items return this.fold(mutableListOf<MutableList<BookChapter>>()) { acc, chapter ->
val maxSelectedId = _selectedIds.value.maxOrNull() ?: return if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter))
else acc.last().add(chapter)
val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId } acc
if (maxIndex == -1) return }.asReversed().flatMap { group ->
if (group.firstOrNull()?.isVolume == true) {
val idsToAppend = currentItems.drop(maxIndex + 1).map { it.id } listOf(group.first()) + group.drop(1).asReversed()
setSelection(_selectedIds.value + idsToAppend) } else {
group.asReversed()
}
}
} }
} }