[优化] 优化目录页面性能和界面
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user