fix: 阅读时换源与并发数量
This commit is contained in:
8 files changed
+542
-377
No files matched your search
@@ -17,13 +17,17 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
|
|||||||
import io.legado.app.utils.internString
|
import io.legado.app.utils.internString
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapMerge
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
sealed interface ChangeSourceSearchEvent {
|
sealed interface ChangeSourceSearchEvent {
|
||||||
data object Started : ChangeSourceSearchEvent
|
data object Started : ChangeSourceSearchEvent
|
||||||
@@ -50,8 +54,9 @@ class ChangeSourceSearchUseCase(
|
|||||||
// Shared state for TOC cache
|
// Shared state for TOC cache
|
||||||
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
|
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
|
||||||
private val bookMap = ConcurrentHashMap<String, Book>()
|
private val bookMap = ConcurrentHashMap<String, Book>()
|
||||||
private var tocMapChapterCount = 0
|
private val tocMapChapterCount = AtomicInteger(0)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun search(
|
fun search(
|
||||||
name: String,
|
name: String,
|
||||||
author: String,
|
author: String,
|
||||||
@@ -64,45 +69,64 @@ class ChangeSourceSearchUseCase(
|
|||||||
if (bookSourceParts.isEmpty()) {
|
if (bookSourceParts.isEmpty()) {
|
||||||
throw io.legado.app.exception.NoStackTraceException("启用书源为空")
|
throw io.legado.app.exception.NoStackTraceException("启用书源为空")
|
||||||
}
|
}
|
||||||
|
val sources = bookSourceParts.mapNotNull { it.getBookSource() }
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
throw io.legado.app.exception.NoStackTraceException("启用书源为空")
|
||||||
|
}
|
||||||
|
|
||||||
tocMap.clear()
|
tocMap.clear()
|
||||||
bookMap.clear()
|
bookMap.clear()
|
||||||
tocMapChapterCount = 0
|
tocMapChapterCount.set(0)
|
||||||
|
|
||||||
emit(ChangeSourceSearchEvent.Started)
|
emit(ChangeSourceSearchEvent.Started)
|
||||||
|
|
||||||
var processedSources = 0
|
var processedSources = 0
|
||||||
val totalSources = bookSourceParts.size
|
var resultCount = 0
|
||||||
|
val totalSources = sources.size
|
||||||
|
val concurrency = threadCount.coerceAtLeast(1)
|
||||||
|
|
||||||
for (bs in bookSourceParts) {
|
sources.asFlow()
|
||||||
currentCoroutineContext().ensureActive()
|
.flatMapMerge(concurrency) { source ->
|
||||||
val source = bs.getBookSource() ?: continue
|
flow {
|
||||||
try {
|
val books = try {
|
||||||
withTimeout(60000L) {
|
withTimeout(60000L) {
|
||||||
searchSource(
|
searchSource(
|
||||||
source, name, author, oldBook, fromReadBookActivity,
|
source, name, author, oldBook, fromReadBookActivity,
|
||||||
contentProcessor
|
contentProcessor
|
||||||
)
|
)
|
||||||
}.forEach { searchBook ->
|
|
||||||
emit(ChangeSourceSearchEvent.Result(searchBook))
|
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
emit(ChangeSourceResult(source, books))
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
.collect { result ->
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
result.books.forEach { searchBook ->
|
||||||
|
resultCount++
|
||||||
|
emit(ChangeSourceSearchEvent.Result(searchBook))
|
||||||
}
|
}
|
||||||
processedSources++
|
processedSources++
|
||||||
emit(
|
emit(
|
||||||
ChangeSourceSearchEvent.Progress(
|
ChangeSourceSearchEvent.Progress(
|
||||||
processedSources = processedSources,
|
processedSources = processedSources,
|
||||||
totalSources = totalSources,
|
totalSources = totalSources,
|
||||||
resultCount = 0,
|
resultCount = resultCount,
|
||||||
sourceName = source.bookSourceName,
|
sourceName = result.source.bookSourceName,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(ChangeSourceSearchEvent.Finished(isEmpty = true))
|
emit(ChangeSourceSearchEvent.Finished(isEmpty = resultCount == 0))
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
private data class ChangeSourceResult(
|
||||||
|
val source: BookSource,
|
||||||
|
val books: List<SearchBook>,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun searchSource(
|
private suspend fun searchSource(
|
||||||
source: BookSource,
|
source: BookSource,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -191,8 +215,8 @@ class ChangeSourceSearchUseCase(
|
|||||||
for (chapter in chapters) {
|
for (chapter in chapters) {
|
||||||
chapter.internString()
|
chapter.internString()
|
||||||
}
|
}
|
||||||
if (tocMapChapterCount < 30000) {
|
if (tocMapChapterCount.get() < 30000) {
|
||||||
tocMapChapterCount += chapters.size
|
tocMapChapterCount.addAndGet(chapters.size)
|
||||||
tocMap[book.primaryStr()] = chapters
|
tocMap[book.primaryStr()] = chapters
|
||||||
}
|
}
|
||||||
bookMap[book.primaryStr()] = book
|
bookMap[book.primaryStr()] = book
|
||||||
|
|||||||
+15
-3
@@ -11,6 +11,7 @@ import io.legado.app.data.repository.SearchRepository
|
|||||||
import io.legado.app.domain.usecase.ChangeSourceSearchEvent
|
import io.legado.app.domain.usecase.ChangeSourceSearchEvent
|
||||||
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
|
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
|
||||||
import io.legado.app.domain.usecase.GetChapterContentUseCase
|
import io.legado.app.domain.usecase.GetChapterContentUseCase
|
||||||
|
import io.legado.app.help.book.isWebFile
|
||||||
import io.legado.app.help.book.primaryStr
|
import io.legado.app.help.book.primaryStr
|
||||||
import io.legado.app.ui.book.search.SearchScope
|
import io.legado.app.ui.book.search.SearchScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -57,8 +58,8 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
private val _searchDataFlow = MutableStateFlow<List<SearchBook>>(emptyList())
|
private val _searchDataFlow = MutableStateFlow<List<SearchBook>>(emptyList())
|
||||||
val searchDataFlow: StateFlow<List<SearchBook>> = _searchDataFlow.asStateFlow()
|
val searchDataFlow: StateFlow<List<SearchBook>> = _searchDataFlow.asStateFlow()
|
||||||
|
|
||||||
val totalSourceCount: Int
|
var totalSourceCount: Int = 0
|
||||||
get() = searchResults.size
|
private set
|
||||||
|
|
||||||
fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook()
|
fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook()
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
// Internal state
|
// Internal state
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var oldBook: Book? = null
|
private var oldBook: Book? = null
|
||||||
|
private var fromReadBookActivity: Boolean = false
|
||||||
private var screenKey: String = ""
|
private var screenKey: String = ""
|
||||||
private val searchResults = mutableListOf<SearchBook>()
|
private val searchResults = mutableListOf<SearchBook>()
|
||||||
private val bookMap = mutableMapOf<String, SearchBook>()
|
private val bookMap = mutableMapOf<String, SearchBook>()
|
||||||
@@ -84,6 +86,7 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
|
|
||||||
fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) {
|
fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) {
|
||||||
this.oldBook = book
|
this.oldBook = book
|
||||||
|
this.fromReadBookActivity = fromReadBookActivity
|
||||||
if (searchJob?.isActive != true) {
|
if (searchJob?.isActive != true) {
|
||||||
startSearch()
|
startSearch()
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,8 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
bookMap.clear()
|
bookMap.clear()
|
||||||
tocMap.clear()
|
tocMap.clear()
|
||||||
|
totalSourceCount = 0
|
||||||
|
_changeSourceProgress.value = 0 to ""
|
||||||
_searchDataFlow.value = emptyList()
|
_searchDataFlow.value = emptyList()
|
||||||
|
|
||||||
searchJob = viewModelScope.launch {
|
searchJob = viewModelScope.launch {
|
||||||
@@ -103,7 +108,7 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
author = book.author,
|
author = book.author,
|
||||||
scope = SearchScope(ChangeSourceConfig.searchScope),
|
scope = SearchScope(ChangeSourceConfig.searchScope),
|
||||||
oldBook = book,
|
oldBook = book,
|
||||||
fromReadBookActivity = false,
|
fromReadBookActivity = fromReadBookActivity,
|
||||||
).collect { event ->
|
).collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChangeSourceSearchEvent.Started -> {
|
is ChangeSourceSearchEvent.Started -> {
|
||||||
@@ -111,6 +116,7 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ChangeSourceSearchEvent.Progress -> {
|
is ChangeSourceSearchEvent.Progress -> {
|
||||||
|
totalSourceCount = event.totalSources
|
||||||
_changeSourceProgress.value = event.processedSources to event.sourceName
|
_changeSourceProgress.value = event.processedSources to event.sourceName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +202,12 @@ class ChangeBookSourceComposeViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (book.isWebFile) {
|
||||||
|
val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
|
||||||
|
?: throw io.legado.app.exception.NoStackTraceException("书源不存在")
|
||||||
|
onSuccess(emptyList(), source)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val (toc, source) = getChapterContentUseCase.getToc(book)
|
val (toc, source) = getChapterContentUseCase.getToc(book)
|
||||||
tocMap[book.primaryStr()] = toc
|
tocMap[book.primaryStr()] = toc
|
||||||
onSuccess(toc, source)
|
onSuccess(toc, source)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
|||||||
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
|
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
|
||||||
import io.legado.app.ui.widget.components.card.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
import io.legado.app.ui.widget.components.card.TextCard
|
import io.legado.app.ui.widget.components.card.TextCard
|
||||||
|
import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
|
||||||
import io.legado.app.ui.widget.components.icon.AppIcon
|
import io.legado.app.ui.widget.components.icon.AppIcon
|
||||||
import io.legado.app.ui.widget.components.image.cover.BookCoverImage
|
import io.legado.app.ui.widget.components.image.cover.BookCoverImage
|
||||||
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import io.legado.app.ui.widget.components.button.series.MediumPlainButton
|
|||||||
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
|
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
|
||||||
import io.legado.app.ui.widget.components.card.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
||||||
|
import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
|
||||||
import io.legado.app.ui.widget.components.checkBox.AppCheckbox
|
import io.legado.app.ui.widget.components.checkBox.AppCheckbox
|
||||||
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||||
@@ -303,356 +304,3 @@ fun ChangeSourceSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChangeSourceSheet(
|
|
||||||
show: Boolean,
|
|
||||||
oldBook: Book,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onReplace: (BookSource, Book, List<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
|
|
||||||
onAddAsNew: (Book, List<BookChapter>) -> Unit,
|
|
||||||
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
|
|
||||||
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
|
|
||||||
val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
|
|
||||||
val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList<String>())
|
|
||||||
val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList<io.legado.app.data.entities.BookSourcePart>())
|
|
||||||
val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle()
|
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
|
||||||
val checkAuthor = viewModel.checkAuthor
|
|
||||||
val loadInfo = viewModel.loadInfo
|
|
||||||
val loadToc = viewModel.loadToc
|
|
||||||
val loadWordCount = viewModel.loadWordCount
|
|
||||||
var actionBook by remember { mutableStateOf<SearchBook?>(null) }
|
|
||||||
var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
|
|
||||||
var showMigrationOptions by remember { mutableStateOf(false) }
|
|
||||||
var loadingAction by remember { mutableStateOf(false) }
|
|
||||||
var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
|
|
||||||
|
|
||||||
val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
|
|
||||||
val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult
|
|
||||||
viewModel.startSearch(origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(oldBook.bookUrl) {
|
|
||||||
viewModel.initData(oldBook.name, oldBook.author, oldBook, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, viewModel) {
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_RESUME -> viewModel.resume()
|
|
||||||
Lifecycle.Event.ON_PAUSE -> viewModel.pause()
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
onDispose {
|
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(oldBook.bookUrl) {
|
|
||||||
onDispose {
|
|
||||||
viewModel.stopSearch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppModalBottomSheet(
|
|
||||||
show = show,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
title = stringResource(R.string.book_source),
|
|
||||||
startAction = {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Box {
|
|
||||||
MediumPlainButton(
|
|
||||||
onClick = { showOptionsMenu = true },
|
|
||||||
icon = Icons.Default.MoreVert
|
|
||||||
)
|
|
||||||
RoundDropdownMenu(
|
|
||||||
expanded = showOptionsMenu,
|
|
||||||
onDismissRequest = { showOptionsMenu = false }
|
|
||||||
) { dismiss ->
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "校验作者",
|
|
||||||
isSelected = checkAuthor,
|
|
||||||
onClick = {
|
|
||||||
viewModel.onCheckAuthorChange(!checkAuthor)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "加载详情",
|
|
||||||
isSelected = loadInfo,
|
|
||||||
onClick = {
|
|
||||||
viewModel.onLoadInfoChange(!loadInfo)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "加载目录",
|
|
||||||
isSelected = loadToc,
|
|
||||||
onClick = {
|
|
||||||
viewModel.onLoadTocChange(!loadToc)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "显示更多信息",
|
|
||||||
isSelected = loadWordCount,
|
|
||||||
onClick = {
|
|
||||||
viewModel.onLoadWordCountChange(!loadWordCount)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.book_source_manage),
|
|
||||||
onClick = {
|
|
||||||
context.startActivity<BookSourceActivity>()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MediumPlainButton(
|
|
||||||
onClick = { showMigrationOptions = true },
|
|
||||||
icon = Icons.Outlined.Settings
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
endAction = {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
MediumPlainButton(
|
|
||||||
onClick = { viewModel.startOrStopSearch() },
|
|
||||||
icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
|
|
||||||
)
|
|
||||||
MediumPlainButton(
|
|
||||||
onClick = { showFilterSheet = true },
|
|
||||||
icon = Icons.Default.FilterList
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
AppTextField(
|
|
||||||
value = searchQuery,
|
|
||||||
backgroundColor = LegadoTheme.colorScheme.surface,
|
|
||||||
onValueChange = {
|
|
||||||
searchQuery = it
|
|
||||||
viewModel.screen(it)
|
|
||||||
},
|
|
||||||
label = stringResource(R.string.screen),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
if (isSearching) {
|
|
||||||
AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
AppText(
|
|
||||||
text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}",
|
|
||||||
style = LegadoTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 40.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
EmptyMessage(
|
|
||||||
message = stringResource(R.string.search_empty)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(items, key = { it.bookUrl + it.origin }) { item ->
|
|
||||||
val bookScore by remember(item.origin, item.name, item.author) {
|
|
||||||
viewModel.bookScoreFlow(item)
|
|
||||||
}.collectAsStateWithLifecycle()
|
|
||||||
SelectionItemCard(
|
|
||||||
title = item.originName,
|
|
||||||
containerColor = LegadoTheme.colorScheme.onSheetContent,
|
|
||||||
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
|
|
||||||
leadingContent = {
|
|
||||||
MediumPlainButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.onBookScoreClick(item)
|
|
||||||
},
|
|
||||||
icon = Icons.Default.PushPin,
|
|
||||||
tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
AppText(
|
|
||||||
text = item.author,
|
|
||||||
style = LegadoTheme.typography.labelLargeEmphasized
|
|
||||||
)
|
|
||||||
AppText(
|
|
||||||
text = item.getDisplayLastChapterTitle(),
|
|
||||||
style = LegadoTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
item.chapterWordCountText?.takeIf { loadWordCount }?.let {
|
|
||||||
AppText(
|
|
||||||
text = it,
|
|
||||||
style = LegadoTheme.typography.labelSmallEmphasized,
|
|
||||||
color = LegadoTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isSelected = item.bookUrl == oldBook.bookUrl,
|
|
||||||
onToggleSelection = {
|
|
||||||
if (item.bookUrl != oldBook.bookUrl) {
|
|
||||||
if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook =
|
|
||||||
item
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dropdownContent = { onDismiss: () -> Unit ->
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.to_top),
|
|
||||||
onClick = {
|
|
||||||
viewModel.topSource(item)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "置底",
|
|
||||||
onClick = {
|
|
||||||
viewModel.bottomSource(item)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.edit),
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
editSourceResult.launch { putExtra("sourceUrl", item.origin) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = "禁用",
|
|
||||||
onClick = {
|
|
||||||
viewModel.disableSource(item)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.delete),
|
|
||||||
color = LegadoTheme.colorScheme.error,
|
|
||||||
onClick = {
|
|
||||||
viewModel.del(item)
|
|
||||||
if (oldBook.bookUrl == item.bookUrl) {
|
|
||||||
viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
|
|
||||||
onReplace(
|
|
||||||
source,
|
|
||||||
book,
|
|
||||||
toc,
|
|
||||||
ChangeSourceConfig.getMigrationOptions()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
|
|
||||||
loadingAction = true
|
|
||||||
val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook()
|
|
||||||
viewModel.getToc(
|
|
||||||
book,
|
|
||||||
onSuccess = { toc, source ->
|
|
||||||
loadingAction = false
|
|
||||||
if (replace) {
|
|
||||||
onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions())
|
|
||||||
onDismissRequest()
|
|
||||||
} else {
|
|
||||||
onAddAsNew(book, toc)
|
|
||||||
context.toastOnUi(bookAddedToShelfText)
|
|
||||||
}
|
|
||||||
actionBook = null
|
|
||||||
},
|
|
||||||
onError = {
|
|
||||||
loadingAction = false
|
|
||||||
context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AppAlertDialog(
|
|
||||||
data = mismatchBook,
|
|
||||||
onDismissRequest = { mismatchBook = null },
|
|
||||||
title = stringResource(R.string.book_type_different),
|
|
||||||
text = stringResource(R.string.soure_change_source),
|
|
||||||
confirmText = stringResource(android.R.string.ok),
|
|
||||||
onConfirm = { searchBook ->
|
|
||||||
actionBook = searchBook
|
|
||||||
mismatchBook = null
|
|
||||||
},
|
|
||||||
dismissText = stringResource(android.R.string.cancel),
|
|
||||||
onDismiss = { mismatchBook = null }
|
|
||||||
)
|
|
||||||
AppAlertDialog(
|
|
||||||
data = actionBook,
|
|
||||||
onDismissRequest = { actionBook = null },
|
|
||||||
title = stringResource(R.string.change_source_option_title),
|
|
||||||
dismissText = stringResource(R.string.add_as_new_book),
|
|
||||||
onDismiss = { actionBook?.let { performAction(it, false) } },
|
|
||||||
confirmText = stringResource(R.string.replace_current_book),
|
|
||||||
onConfirm = { performAction(it, true) }
|
|
||||||
)
|
|
||||||
AppAlertDialog(
|
|
||||||
show = loadingAction,
|
|
||||||
onDismissRequest = {},
|
|
||||||
content = {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
|
||||||
AppCircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ChangeSourceMigrationOptionsSheet(
|
|
||||||
show = showMigrationOptions,
|
|
||||||
title = "换源选项",
|
|
||||||
onDismissRequest = { showMigrationOptions = false },
|
|
||||||
onConfirm = { options ->
|
|
||||||
ChangeSourceConfig.setMigrationOptions(options)
|
|
||||||
showMigrationOptions = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ScopeSelectSheet(
|
|
||||||
show = showFilterSheet,
|
|
||||||
onDismissRequest = { showFilterSheet = false },
|
|
||||||
isAll = scopeState.isAll,
|
|
||||||
onSelectAll = { viewModel.selectAllScope() },
|
|
||||||
groups = groups,
|
|
||||||
selectedGroups = scopeState.displayNames,
|
|
||||||
onToggleGroup = { viewModel.toggleScopeGroup(it) },
|
|
||||||
sources = enabledSources,
|
|
||||||
selectedSources = scopeState.sourceUrls,
|
|
||||||
onToggleSource = { viewModel.toggleScopeSource(it) },
|
|
||||||
isSourceScope = scopeState.isSource,
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.startSearch()
|
|
||||||
showFilterSheet = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -260,6 +260,7 @@ sealed interface ReadBookIntent {
|
|||||||
data object ToggleTranslation : ReadBookIntent
|
data object ToggleTranslation : ReadBookIntent
|
||||||
|
|
||||||
// Change source
|
// Change source
|
||||||
|
data class ChangeSourceBook(val book: Book) : ReadBookIntent
|
||||||
data class ChangeSource(val book: Book, val toc: List<BookChapter>) : ReadBookIntent
|
data class ChangeSource(val book: Book, val toc: List<BookChapter>) : ReadBookIntent
|
||||||
data class AddSourceAsNewBook(val book: Book, val toc: List<BookChapter>) : ReadBookIntent
|
data class AddSourceAsNewBook(val book: Book, val toc: List<BookChapter>) : ReadBookIntent
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.ui.book.info.ChangeSourceSheet
|
|
||||||
import io.legado.app.ui.widget.components.log.AppLogSheet
|
import io.legado.app.ui.widget.components.log.AppLogSheet
|
||||||
import io.legado.app.ui.book.read.sheet.BgTextConfigSheet
|
import io.legado.app.ui.book.read.sheet.BgTextConfigSheet
|
||||||
import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet
|
import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet
|
||||||
@@ -36,6 +35,7 @@ import io.legado.app.ui.book.read.sheet.TitleBarIconSheet
|
|||||||
import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
|
import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
|
||||||
import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
|
import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
|
||||||
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||||
|
import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
@@ -397,11 +397,17 @@ fun ReadBookScreen(
|
|||||||
ChangeSourceSheet(
|
ChangeSourceSheet(
|
||||||
show = true,
|
show = true,
|
||||||
oldBook = book,
|
oldBook = book,
|
||||||
|
fromReadBookActivity = true,
|
||||||
|
allowAddAsNew = false,
|
||||||
|
dismissOnReplaceStart = true,
|
||||||
onDismissRequest = { onIntent(ReadBookIntent.DismissSheet) },
|
onDismissRequest = { onIntent(ReadBookIntent.DismissSheet) },
|
||||||
onReplace = { _, newBook, toc, _ ->
|
onReplace = { _, newBook, toc, _ ->
|
||||||
onIntent(ReadBookIntent.DismissSheet)
|
onIntent(ReadBookIntent.DismissSheet)
|
||||||
onIntent(ReadBookIntent.ChangeSource(newBook, toc))
|
onIntent(ReadBookIntent.ChangeSource(newBook, toc))
|
||||||
},
|
},
|
||||||
|
onReplaceBook = { newBook ->
|
||||||
|
onIntent(ReadBookIntent.ChangeSourceBook(newBook))
|
||||||
|
},
|
||||||
onAddAsNew = { newBook, toc ->
|
onAddAsNew = { newBook, toc ->
|
||||||
onIntent(ReadBookIntent.DismissSheet)
|
onIntent(ReadBookIntent.DismissSheet)
|
||||||
onIntent(ReadBookIntent.AddSourceAsNewBook(newBook, toc))
|
onIntent(ReadBookIntent.AddSourceAsNewBook(newBook, toc))
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ class ReadBookViewModel(
|
|||||||
is ReadBookIntent.RefreshContentAfter -> refreshContentAfter()
|
is ReadBookIntent.RefreshContentAfter -> refreshContentAfter()
|
||||||
is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled)
|
is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled)
|
||||||
is ReadBookIntent.ToggleTranslation -> toggleTranslation()
|
is ReadBookIntent.ToggleTranslation -> toggleTranslation()
|
||||||
|
is ReadBookIntent.ChangeSourceBook -> changeTo(intent.book)
|
||||||
is ReadBookIntent.ChangeSource -> changeTo(intent.book, intent.toc)
|
is ReadBookIntent.ChangeSource -> changeTo(intent.book, intent.toc)
|
||||||
is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc)
|
is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc)
|
||||||
is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos)
|
is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos)
|
||||||
@@ -1810,6 +1811,38 @@ class ReadBookViewModel(
|
|||||||
changeSourceCoroutine?.cancel()
|
changeSourceCoroutine?.cancel()
|
||||||
changeSourceCoroutine = execute {
|
changeSourceCoroutine = execute {
|
||||||
ReadBook.upMsg(context.getString(R.string.loading))
|
ReadBook.upMsg(context.getString(R.string.loading))
|
||||||
|
applyChangeSource(book, toc)
|
||||||
|
}.onError {
|
||||||
|
AppLog.put("换源失败\n$it", it, true)
|
||||||
|
ReadBook.upMsg(null)
|
||||||
|
}.onFinally {
|
||||||
|
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeTo(book: Book) {
|
||||||
|
changeSourceCoroutine?.cancel()
|
||||||
|
changeSourceCoroutine = execute {
|
||||||
|
ReadBook.upMsg(context.getString(R.string.loading))
|
||||||
|
val source = appDb.bookSourceDao.getBookSource(book.origin)
|
||||||
|
?: throw NoStackTraceException("书源不存在")
|
||||||
|
if (book.tocUrl.isEmpty()) {
|
||||||
|
WebBook.getBookInfoAwait(source, book)
|
||||||
|
}
|
||||||
|
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
|
||||||
|
applyChangeSource(book, toc)
|
||||||
|
}.onError {
|
||||||
|
AppLog.put("换源失败\n$it", it, true)
|
||||||
|
ReadBook.upMsg(null)
|
||||||
|
}.onFinally {
|
||||||
|
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyChangeSource(book: Book, toc: List<BookChapter>) {
|
||||||
|
if (toc.isEmpty()) {
|
||||||
|
throw NoStackTraceException("换源目录为空")
|
||||||
|
}
|
||||||
ReadBook.book?.migrateTo(book, toc)
|
ReadBook.book?.migrateTo(book, toc)
|
||||||
book.removeType(BookType.updateError)
|
book.removeType(BookType.updateError)
|
||||||
ReadBook.book?.delete()
|
ReadBook.book?.delete()
|
||||||
@@ -1818,12 +1851,6 @@ class ReadBookViewModel(
|
|||||||
ReadBook.resetData(book)
|
ReadBook.resetData(book)
|
||||||
ReadBook.upMsg(null)
|
ReadBook.upMsg(null)
|
||||||
ReadBook.loadContent(resetPageOffset = true)
|
ReadBook.loadContent(resetPageOffset = true)
|
||||||
}.onError {
|
|
||||||
AppLog.put("换源失败\n$it", it, true)
|
|
||||||
ReadBook.upMsg(null)
|
|
||||||
}.onFinally {
|
|
||||||
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun autoChangeSource(name: String, author: String) {
|
private fun autoChangeSource(name: String, author: String) {
|
||||||
|
|||||||
+446
@@ -0,0 +1,446 @@
|
|||||||
|
package io.legado.app.ui.widget.components.changeSource
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||||
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.data.entities.Book
|
||||||
|
import io.legado.app.data.entities.BookChapter
|
||||||
|
import io.legado.app.data.entities.BookSource
|
||||||
|
import io.legado.app.data.entities.SearchBook
|
||||||
|
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
|
||||||
|
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
|
||||||
|
import io.legado.app.ui.book.changesource.ChangeSourceConfig
|
||||||
|
import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
|
||||||
|
import io.legado.app.ui.book.search.ScopeSelectSheet
|
||||||
|
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||||
|
import io.legado.app.ui.book.source.manage.BookSourceActivity
|
||||||
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.widget.components.AppTextField
|
||||||
|
import io.legado.app.ui.widget.components.EmptyMessage
|
||||||
|
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||||
|
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
|
||||||
|
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
||||||
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||||
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
|
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
|
||||||
|
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
|
||||||
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
|
import io.legado.app.utils.StartActivityContract
|
||||||
|
import io.legado.app.utils.startActivity
|
||||||
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChangeSourceSheet(
|
||||||
|
show: Boolean,
|
||||||
|
oldBook: Book,
|
||||||
|
fromReadBookActivity: Boolean = false,
|
||||||
|
allowAddAsNew: Boolean = true,
|
||||||
|
dismissOnReplaceStart: Boolean = false,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onReplace: (BookSource, Book, List<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
|
||||||
|
onReplaceBook: ((Book) -> Unit)? = null,
|
||||||
|
onAddAsNew: (Book, List<BookChapter>) -> Unit,
|
||||||
|
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
|
||||||
|
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
|
||||||
|
val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
|
||||||
|
val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList<String>())
|
||||||
|
val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList<io.legado.app.data.entities.BookSourcePart>())
|
||||||
|
val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle()
|
||||||
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
val checkAuthor = viewModel.checkAuthor
|
||||||
|
val loadInfo = viewModel.loadInfo
|
||||||
|
val loadToc = viewModel.loadToc
|
||||||
|
val loadWordCount = viewModel.loadWordCount
|
||||||
|
var actionBook by remember { mutableStateOf<SearchBook?>(null) }
|
||||||
|
var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
|
||||||
|
var showMigrationOptions by remember { mutableStateOf(false) }
|
||||||
|
var loadingAction by remember { mutableStateOf(false) }
|
||||||
|
var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
|
||||||
|
|
||||||
|
val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
|
||||||
|
val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult
|
||||||
|
viewModel.startSearch(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(oldBook.bookUrl, fromReadBookActivity) {
|
||||||
|
viewModel.initData(oldBook.name, oldBook.author, oldBook, fromReadBookActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, viewModel) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_RESUME -> viewModel.resume()
|
||||||
|
Lifecycle.Event.ON_PAUSE -> viewModel.pause()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(oldBook.bookUrl) {
|
||||||
|
onDispose {
|
||||||
|
viewModel.stopSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val performAction = fun(searchBook: SearchBook, replace: Boolean) {
|
||||||
|
val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook()
|
||||||
|
if (replace && dismissOnReplaceStart && onReplaceBook != null) {
|
||||||
|
onDismissRequest()
|
||||||
|
onReplaceBook(book)
|
||||||
|
actionBook = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dismissBeforeLoading = replace && dismissOnReplaceStart
|
||||||
|
if (dismissBeforeLoading) {
|
||||||
|
onDismissRequest()
|
||||||
|
} else {
|
||||||
|
loadingAction = true
|
||||||
|
}
|
||||||
|
viewModel.getToc(
|
||||||
|
book,
|
||||||
|
onSuccess = { toc, source ->
|
||||||
|
loadingAction = false
|
||||||
|
if (replace) {
|
||||||
|
onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions())
|
||||||
|
if (!dismissBeforeLoading) {
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onAddAsNew(book, toc)
|
||||||
|
context.toastOnUi(bookAddedToShelfText)
|
||||||
|
}
|
||||||
|
actionBook = null
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
loadingAction = false
|
||||||
|
context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppModalBottomSheet(
|
||||||
|
show = show,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = stringResource(R.string.book_source),
|
||||||
|
startAction = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Box {
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = { showOptionsMenu = true },
|
||||||
|
icon = Icons.Default.MoreVert
|
||||||
|
)
|
||||||
|
RoundDropdownMenu(
|
||||||
|
expanded = showOptionsMenu,
|
||||||
|
onDismissRequest = { showOptionsMenu = false }
|
||||||
|
) { dismiss ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "校验作者",
|
||||||
|
isSelected = checkAuthor,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onCheckAuthorChange(!checkAuthor)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "加载详情",
|
||||||
|
isSelected = loadInfo,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onLoadInfoChange(!loadInfo)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "加载目录",
|
||||||
|
isSelected = loadToc,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onLoadTocChange(!loadToc)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "显示更多信息",
|
||||||
|
isSelected = loadWordCount,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onLoadWordCountChange(!loadWordCount)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.book_source_manage),
|
||||||
|
onClick = {
|
||||||
|
context.startActivity<BookSourceActivity>()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = { showMigrationOptions = true },
|
||||||
|
icon = Icons.Outlined.Settings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
endAction = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = { viewModel.startOrStopSearch() },
|
||||||
|
icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
|
||||||
|
)
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = { showFilterSheet = true },
|
||||||
|
icon = Icons.Default.FilterList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AppTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||||
|
onValueChange = {
|
||||||
|
searchQuery = it
|
||||||
|
viewModel.screen(it)
|
||||||
|
},
|
||||||
|
label = stringResource(R.string.screen),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
if (isSearching) {
|
||||||
|
AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
AppText(
|
||||||
|
text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}",
|
||||||
|
style = LegadoTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 40.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
EmptyMessage(
|
||||||
|
message = stringResource(R.string.search_empty)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(items, key = { it.bookUrl + it.origin }) { item ->
|
||||||
|
val bookScore by remember(item.origin, item.name, item.author) {
|
||||||
|
viewModel.bookScoreFlow(item)
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
SelectionItemCard(
|
||||||
|
title = item.originName,
|
||||||
|
containerColor = LegadoTheme.colorScheme.onSheetContent,
|
||||||
|
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
|
||||||
|
leadingContent = {
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onBookScoreClick(item)
|
||||||
|
},
|
||||||
|
icon = Icons.Default.PushPin,
|
||||||
|
tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
AppText(
|
||||||
|
text = item.author,
|
||||||
|
style = LegadoTheme.typography.labelLargeEmphasized
|
||||||
|
)
|
||||||
|
AppText(
|
||||||
|
text = item.getDisplayLastChapterTitle(),
|
||||||
|
style = LegadoTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
item.chapterWordCountText?.takeIf { loadWordCount }?.let {
|
||||||
|
AppText(
|
||||||
|
text = it,
|
||||||
|
style = LegadoTheme.typography.labelSmallEmphasized,
|
||||||
|
color = LegadoTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSelected = item.bookUrl == oldBook.bookUrl,
|
||||||
|
onToggleSelection = {
|
||||||
|
if (item.bookUrl != oldBook.bookUrl) {
|
||||||
|
if (!item.sameBookTypeLocal(oldBook.type)) {
|
||||||
|
mismatchBook = item
|
||||||
|
} else if (allowAddAsNew) {
|
||||||
|
actionBook = item
|
||||||
|
} else {
|
||||||
|
performAction(item, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropdownContent = { onDismiss: () -> Unit ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.to_top),
|
||||||
|
onClick = {
|
||||||
|
viewModel.topSource(item)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "置底",
|
||||||
|
onClick = {
|
||||||
|
viewModel.bottomSource(item)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.edit),
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
editSourceResult.launch { putExtra("sourceUrl", item.origin) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = "禁用",
|
||||||
|
onClick = {
|
||||||
|
viewModel.disableSource(item)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.delete),
|
||||||
|
color = LegadoTheme.colorScheme.error,
|
||||||
|
onClick = {
|
||||||
|
viewModel.del(item)
|
||||||
|
if (oldBook.bookUrl == item.bookUrl) {
|
||||||
|
viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
|
||||||
|
onReplace(
|
||||||
|
source,
|
||||||
|
book,
|
||||||
|
toc,
|
||||||
|
ChangeSourceConfig.getMigrationOptions()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
AppAlertDialog(
|
||||||
|
data = mismatchBook,
|
||||||
|
onDismissRequest = { mismatchBook = null },
|
||||||
|
title = stringResource(R.string.book_type_different),
|
||||||
|
text = stringResource(R.string.soure_change_source),
|
||||||
|
confirmText = stringResource(android.R.string.ok),
|
||||||
|
onConfirm = { searchBook ->
|
||||||
|
mismatchBook = null
|
||||||
|
if (allowAddAsNew) {
|
||||||
|
actionBook = searchBook
|
||||||
|
} else {
|
||||||
|
performAction(searchBook, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissText = stringResource(android.R.string.cancel),
|
||||||
|
onDismiss = { mismatchBook = null }
|
||||||
|
)
|
||||||
|
if (allowAddAsNew) {
|
||||||
|
AppAlertDialog(
|
||||||
|
data = actionBook,
|
||||||
|
onDismissRequest = { actionBook = null },
|
||||||
|
title = stringResource(R.string.change_source_option_title),
|
||||||
|
dismissText = stringResource(R.string.add_as_new_book),
|
||||||
|
onDismiss = { actionBook?.let { performAction(it, false) } },
|
||||||
|
confirmText = stringResource(R.string.replace_current_book),
|
||||||
|
onConfirm = { performAction(it, true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppAlertDialog(
|
||||||
|
show = loadingAction,
|
||||||
|
onDismissRequest = {},
|
||||||
|
content = {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
AppCircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChangeSourceMigrationOptionsSheet(
|
||||||
|
show = showMigrationOptions,
|
||||||
|
title = "换源选项",
|
||||||
|
onDismissRequest = { showMigrationOptions = false },
|
||||||
|
onConfirm = { options ->
|
||||||
|
ChangeSourceConfig.setMigrationOptions(options)
|
||||||
|
showMigrationOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ScopeSelectSheet(
|
||||||
|
show = showFilterSheet,
|
||||||
|
onDismissRequest = { showFilterSheet = false },
|
||||||
|
isAll = scopeState.isAll,
|
||||||
|
onSelectAll = { viewModel.selectAllScope() },
|
||||||
|
groups = groups,
|
||||||
|
selectedGroups = scopeState.displayNames,
|
||||||
|
onToggleGroup = { viewModel.toggleScopeGroup(it) },
|
||||||
|
sources = enabledSources,
|
||||||
|
selectedSources = scopeState.sourceUrls,
|
||||||
|
onToggleSource = { viewModel.toggleScopeSource(it) },
|
||||||
|
isSourceScope = scopeState.isSource,
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.startSearch()
|
||||||
|
showFilterSheet = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user