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