换源时,不会再因为打开浏览器而导致重走换源流程

换源界面新增多选分组和书源功能
This commit is contained in:
HapeLee
2026-05-11 03:28:12 +08:00
parent 920d4058e7
commit 45e678b1ae
13 changed files with 508 additions and 210 deletions
@@ -172,6 +172,16 @@ interface BookSourceDao {
)
fun getEnabledPartByGroup(group: String): List<BookSourcePart>
@Query(
"""select * from book_sources_part
where bookSourceGroup = :group
or bookSourceGroup like :group || ',%'
or bookSourceGroup like '%,' || :group
or bookSourceGroup like '%,' || :group || ',%'
order by customOrder asc"""
)
fun getPartByGroup(group: String): List<BookSourcePart>
@Query(
"""select * from book_sources
where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc"""
@@ -11,6 +11,10 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) {
return bookGroupDao.flowAll()
}
fun flowSelect(): Flow<List<BookGroup>> {
return bookGroupDao.flowSelect()
}
fun flowShow(): Flow<List<BookGroup>> {
return bookGroupDao.show.asFlow()
}
@@ -1,14 +1,42 @@
package io.legado.app.ui.book.changesource
import android.app.Application
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.SearchRepository
import io.legado.app.ui.book.search.SearchScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class ChangeBookSourceComposeViewModel(application: Application) :
ChangeBookSourceViewModel(application) {
class ChangeBookSourceComposeViewModel(
application: Application,
private val searchRepository: SearchRepository,
) : ChangeBookSourceViewModel(application) {
val searchGroup: String
get() = ChangeSourceConfig.searchGroup
val enabledGroups = searchRepository.enabledGroups
val enabledSources = searchRepository.enabledSources
val searchScope = SearchScope(ChangeSourceConfig.searchScope)
data class ScopeUiState(
val isAll: Boolean,
val isSource: Boolean,
val displayNames: List<String>,
val sourceUrls: List<String>
)
private val _scopeUiState = MutableStateFlow(
ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
)
val scopeUiState = _scopeUiState.asStateFlow()
val checkAuthor: Boolean
get() = ChangeSourceConfig.checkAuthor
@@ -22,12 +50,6 @@ class ChangeBookSourceComposeViewModel(application: Application) :
val loadWordCount: Boolean
get() = ChangeSourceConfig.loadWordCount
fun onSearchGroupSelected(group: String) {
if (ChangeSourceConfig.searchGroup == group) return
ChangeSourceConfig.searchGroup = group
if (refresh()) startSearch()
}
fun onCheckAuthorChange(enabled: Boolean) {
if (ChangeSourceConfig.checkAuthor == enabled) return
ChangeSourceConfig.checkAuthor = enabled
@@ -62,4 +84,60 @@ class ChangeBookSourceComposeViewModel(application: Application) :
val currentScore = ObservableSourceConfig.getBookScore(searchBook)
setBookScore(searchBook, if (currentScore > 0) 0 else 1)
}
fun selectAllScope() {
searchScope.update("")
saveScope()
}
fun toggleScopeGroup(groupName: String) {
if (searchScope.isSource()) {
searchScope.update("")
}
val selected = searchScope.displayNames.toMutableSet()
if (selected.contains(groupName)) {
selected.remove(groupName)
} else {
selected.add(groupName)
}
searchScope.update(selected.toList())
saveScope()
}
fun toggleScopeSource(source: BookSourcePart) {
val selectedUrls = if (searchScope.isSource()) {
searchScope.sourceUrls.toMutableSet()
} else {
mutableSetOf()
}
if (selectedUrls.contains(source.bookSourceUrl)) {
selectedUrls.remove(source.bookSourceUrl)
} else {
selectedUrls.add(source.bookSourceUrl)
}
if (selectedUrls.isEmpty()) {
searchScope.update("")
} else {
val selectedSources = appDb.bookSourceDao.allEnabledPart.filter {
selectedUrls.contains(it.bookSourceUrl)
}
searchScope.updateSources(selectedSources)
}
saveScope()
}
private fun saveScope() {
ChangeSourceConfig.searchScope = searchScope.toString()
_scopeUiState.update {
ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
}
refresh()
}
}
@@ -38,6 +38,7 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -97,6 +98,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
.thenBy { it.originOrder }
}
private var task: Job? = null
private var isPaused = false
private var wasSearching = false
val bookMap = ConcurrentHashMap<String, Book>()
val searchDataFlow = callbackFlow {
@@ -119,13 +122,14 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
}
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
trySend(arrayOf(searchBooks))
}
if (searchBooks.isEmpty()) {
getDbSearchBooks().let {
searchBooks.addAll(it)
}
}
trySend(arrayOf(searchBooks))
if (searchBooks.isEmpty() && !_isSearching.value) {
startSearch()
}
@@ -207,18 +211,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
bookMap.clear()
tocMapChapterCount = 0
_changeSourceProgress.value = 0 to ""
val searchGroup = AppConfig.searchGroup
if (searchGroup.isBlank()) {
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup)
if (sources.isEmpty()) {
AppConfig.searchGroup = ""
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
bookSourceParts.addAll(sources)
}
}
bookSourceParts.addAll(io.legado.app.ui.book.search.SearchScope(ChangeSourceConfig.searchScope).getBookSourceParts())
initSearchPool()
search()
}
@@ -250,6 +243,9 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchStateData.postValue(true)
_isSearching.value = true
}.mapParallel(threadCount) {
while (isPaused) {
kotlinx.coroutines.delay(100)
}
try {
withTimeout(60000L) {
search(it)
@@ -465,6 +461,17 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchPool?.close()
searchStateData.postValue(false)
_isSearching.value = false
wasSearching = false
}
fun pause() {
isPaused = true
wasSearching = _isSearching.value
}
fun resume() {
isPaused = false
wasSearching = false
}
fun getToc(
@@ -1,6 +1,7 @@
package io.legado.app.ui.book.changesource
import io.legado.app.constant.PreferKey
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.ui.config.prefDelegate
object ChangeSourceConfig {
@@ -10,6 +11,11 @@ object ChangeSourceConfig {
defaultValue = ""
)
var searchScope by prefDelegate(
key = "changeSourceSearchScope",
defaultValue = ""
)
var checkAuthor by prefDelegate(
key = PreferKey.changeSourceCheckAuthor,
defaultValue = false
@@ -29,4 +35,61 @@ object ChangeSourceConfig {
key = PreferKey.changeSourceLoadWordCount,
defaultValue = false
)
var migrateChapters by prefDelegate(
key = "migrateChapters",
defaultValue = true
)
var migrateReadingProgress by prefDelegate(
key = "migrateReadingProgress",
defaultValue = true
)
var migrateGroup by prefDelegate(
key = "migrateGroup",
defaultValue = true
)
var migrateCover by prefDelegate(
key = "migrateCover",
defaultValue = true
)
var migrateCategory by prefDelegate(
key = "migrateCategory",
defaultValue = true
)
var migrateRemark by prefDelegate(
key = "migrateRemark",
defaultValue = true
)
var migrateReadConfig by prefDelegate(
key = "migrateReadConfig",
defaultValue = true
)
var deleteDownloadedChapters by prefDelegate(
key = "deleteDownloadedChapters",
defaultValue = false
)
fun getMigrationOptions(): ChangeSourceMigrationOptions {
return ChangeSourceMigrationOptions(
migrateChapters = migrateChapters,
migrateReadingProgress = migrateReadingProgress,
migrateGroup = migrateGroup,
migrateCover = migrateCover,
migrateCategory = migrateCategory,
migrateRemark = migrateRemark,
migrateReadConfig = migrateReadConfig,
deleteDownloadedChapters = deleteDownloadedChapters,
)
}
fun setMigrationOptions(options: ChangeSourceMigrationOptions) {
migrateChapters = options.migrateChapters
migrateReadingProgress = options.migrateReadingProgress
migrateGroup = options.migrateGroup
migrateCover = options.migrateCover
migrateCategory = options.migrateCategory
migrateRemark = options.migrateRemark
migrateReadConfig = options.migrateReadConfig
deleteDownloadedChapters = options.deleteDownloadedChapters
}
}
@@ -30,13 +30,27 @@ fun ChangeSourceMigrationOptionsSheet(
onDismissRequest: () -> Unit,
onConfirm: (ChangeSourceMigrationOptions) -> Unit,
) {
var migrateReadingProgress by rememberSaveable(show) { mutableStateOf(true) }
var migrateGroup by rememberSaveable(show) { mutableStateOf(true) }
var migrateCover by rememberSaveable(show) { mutableStateOf(true) }
var migrateCategory by rememberSaveable(show) { mutableStateOf(true) }
var migrateRemark by rememberSaveable(show) { mutableStateOf(true) }
var migrateReadConfig by rememberSaveable(show) { mutableStateOf(true) }
var deleteDownloadedChapters by rememberSaveable(show) { mutableStateOf(false) }
var migrateReadingProgress by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateReadingProgress)
}
var migrateGroup by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateGroup)
}
var migrateCover by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateCover)
}
var migrateCategory by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateCategory)
}
var migrateRemark by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateRemark)
}
var migrateReadConfig by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.migrateReadConfig)
}
var deleteDownloadedChapters by rememberSaveable(show) {
mutableStateOf(ChangeSourceConfig.deleteDownloadedChapters)
}
AppModalBottomSheet(
show = show,
@@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import coil.compose.AsyncImage
import io.legado.app.R
@@ -282,12 +283,16 @@ private fun BookInfoScreenContent(
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
)
BookInfoSheet.GroupPicker -> GroupSelectSheet(
show = currentSheet == BookInfoSheet.GroupPicker,
currentGroupId = state.book?.group ?: 0L,
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
)
BookInfoSheet.GroupPicker -> {
val groups by koinInject<io.legado.app.data.repository.BookGroupRepository>().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
GroupSelectSheet(
show = currentSheet == BookInfoSheet.GroupPicker,
groups = groups,
currentGroupId = state.book?.group ?: 0L,
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
)
}
BookInfoSheet.SourcePicker -> state.book?.let { book ->
ChangeSourceSheet(
show = currentSheet == BookInfoSheet.SourcePicker,
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MoreVert
@@ -36,6 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,19 +47,22 @@ 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.text.style.TextAlign
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.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup
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.help.book.isSameNameAuthor
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
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.group.GroupEditSheet
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
@@ -65,6 +70,7 @@ import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
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.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton
@@ -76,6 +82,7 @@ import io.legado.app.ui.widget.components.cover.CoilBookCover
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.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.startActivity
@@ -118,11 +125,11 @@ fun WebFileSheet(
@Composable
fun GroupSelectSheet(
show: Boolean,
groups: List<BookGroup>,
currentGroupId: Long,
onDismissRequest: () -> Unit,
onConfirm: (Long) -> Unit,
) {
val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) }
var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
@@ -138,7 +145,7 @@ fun GroupSelectSheet(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(groups, key = { it.groupId }) { group ->
val isSelected = selectedGroupId and group.groupId > 0
val isSelected = (selectedGroupId and group.groupId) != 0L
SelectionItemCard(
title = group.groupName,
isSelected = isSelected,
@@ -194,7 +201,7 @@ fun ChangeCoverSheet(
onSelect: (String) -> Unit,
viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
) {
val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
LaunchedEffect(name, author) {
@@ -244,22 +251,25 @@ fun ChangeSourceSheet(
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
) {
val context = LocalContext.current
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
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 appDb.bookSourceDao.flowEnabledGroups().collectAsStateWithLifecycle(initialValue = emptyList())
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 selectedGroup = viewModel.searchGroup
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 pendingMigration by remember { mutableStateOf<PendingSourceMigration?>(null) }
var showMigrationOptions by remember { mutableStateOf(false) }
var loadingAction by remember { mutableStateOf(false) }
var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
var showFilterMenu by rememberSaveable { mutableStateOf(false) }
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) }
val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
@@ -270,6 +280,21 @@ fun ChangeSourceSheet(
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()
@@ -323,10 +348,17 @@ fun ChangeSourceSheet(
dismiss()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.book_source_manage),
onClick = {
context.startActivity<BookSourceActivity>()
dismiss()
}
)
}
}
MediumIconButton(
onClick = { context.startActivity<BookSourceActivity>() },
onClick = { showMigrationOptions = true },
imageVector = Icons.Outlined.Settings
)
}
@@ -337,35 +369,10 @@ fun ChangeSourceSheet(
onClick = { viewModel.startOrStopSearch() },
imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
)
Box {
MediumIconButton(
onClick = { showFilterMenu = true },
imageVector = Icons.Default.FilterList
)
RoundDropdownMenu(
expanded = showFilterMenu,
onDismissRequest = { showFilterMenu = false }
) { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.all_source),
isSelected = selectedGroup.isBlank(),
onClick = {
viewModel.onSearchGroupSelected("")
dismiss()
}
)
groups.forEach { group ->
RoundDropdownMenuItem(
text = group,
isSelected = selectedGroup == group,
onClick = {
viewModel.onSearchGroupSelected(group)
dismiss()
}
)
}
}
}
MediumIconButton(
onClick = { showFilterSheet = true },
imageVector = Icons.Default.FilterList
)
}
}
) {
@@ -389,95 +396,116 @@ fun ChangeSourceSheet(
)
Spacer(modifier = Modifier.height(12.dp))
}
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 = {
MediumIconButton(
onClick = {
viewModel.onBookScoreClick(item)
},
imageVector = 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 ->
pendingMigration = PendingSourceMigration(source, book, toc)
}
}
onDismiss()
}
)
}
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 = {
MediumIconButton(
onClick = {
viewModel.onBookScoreClick(item)
},
imageVector = 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))
}
@@ -485,19 +513,24 @@ fun ChangeSourceSheet(
val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
loadingAction = true
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
viewModel.getToc(book, { toc, source ->
loadingAction = false
if (replace) {
pendingMigration = PendingSourceMigration(source, book, toc)
} else {
onAddAsNew(book, toc)
context.toastOnUi(bookAddedToShelfText)
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 "添加书籍失败")
}
actionBook = null
}, {
loadingAction = false
context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
})
)
}
AppAlertDialog(
@@ -531,30 +564,103 @@ fun ChangeSourceSheet(
}
}
)
val migration = pendingMigration
ChangeSourceMigrationOptionsSheet(
show = migration != null,
show = showMigrationOptions,
title = "换源选项",
subtitle = migration?.let {
val sameNameAuthor = oldBook.isSameNameAuthor(it.book)
if (sameNameAuthor && oldBook.origin != it.book.origin) {
"检测到书名、作者相同但书源不同,可选择本次要迁移的数据。"
} else {
"选择本次替换当前书籍时要迁移的数据。"
}
},
onDismissRequest = { pendingMigration = null },
onDismissRequest = { showMigrationOptions = false },
onConfirm = { options ->
val pending = pendingMigration ?: return@ChangeSourceMigrationOptionsSheet
onReplace(pending.source, pending.book, pending.toc, options)
pendingMigration = null
onDismissRequest()
ChangeSourceConfig.setMigrationOptions(options)
showMigrationOptions = false
}
)
AppModalBottomSheet(
show = showFilterSheet,
onDismissRequest = { showFilterSheet = false },
title = stringResource(R.string.search_select_group),
endAction = {
MediumIconButton(
onClick = {
viewModel.startSearch()
showFilterSheet = false
},
imageVector = Icons.Default.Check
)
}
) {
Column {
SelectionItemCard(
title = stringResource(R.string.all_source),
isSelected = scopeState.isAll,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true,
onToggleSelection = {
viewModel.selectAllScope()
}
)
Spacer(modifier = Modifier.height(8.dp))
AppTabRow(
tabTitles = listOf(
stringResource(R.string.group),
stringResource(R.string.book_source),
),
selectedTabIndex = scopeSheetTab,
onTabSelected = { scopeSheetTab = it },
)
Spacer(modifier = Modifier.height(6.dp))
if (scopeSheetTab == 0) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(groups, key = { it }) {
val selected = !scopeState.isSource && scopeState.displayNames.contains(it)
SelectionItemCard(
title = it,
isSelected = selected,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true,
onToggleSelection = {
viewModel.toggleScopeGroup(it)
}
)
}
}
} else {
if (enabledSources.isEmpty()) {
Text(
text = stringResource(R.string.search_empty),
style = LegadoTheme.typography.bodyMedium,
color = LegadoTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(enabledSources, key = { it.bookSourceUrl }) {
val selected = scopeState.sourceUrls.contains(it.bookSourceUrl)
SelectionItemCard(
title = it.bookSourceName,
subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
isSelected = selected,
inSelectionMode = true,
onToggleSelection = {
viewModel.toggleScopeSource(it)
}
)
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
}
}
}
private data class PendingSourceMigration(
val source: BookSource,
val book: Book,
val toc: List<BookChapter>,
)
@@ -20,6 +20,7 @@ import io.legado.app.domain.usecase.ChangeBookSourceUseCase
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.exception.NoBooksDirException
import io.legado.app.exception.NoStackTraceException
@@ -68,9 +69,12 @@ class BookInfoViewModel(
private val remoteBookRepository: RemoteBookRepository,
private val readRecordRepository: ReadRecordRepository,
private val changeBookSourceUseCase: ChangeBookSourceUseCase,
private val clearBookCacheUseCase: ClearBookCacheUseCase
private val clearBookCacheUseCase: ClearBookCacheUseCase,
private val bookGroupRepository: BookGroupRepository,
) : BaseViewModel(application) {
val allGroups = bookGroupRepository.flowAll()
private val _uiState = MutableStateFlow(BookInfoUiState())
val uiState = _uiState.asStateFlow()
@@ -72,6 +72,7 @@ import io.legado.app.R
import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
@@ -1056,6 +1057,7 @@ private fun BookshelfManageScreen(
GroupSelectSheet(
show = showGroupSelectSheet,
groups = state.groupList,
currentGroupId = groupPickerCurrentGroupId,
onDismissRequest = { showGroupSelectSheet = false },
onConfirm = { groupId ->
@@ -39,6 +39,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -103,7 +104,7 @@ fun SearchScreen(
val listState = rememberLazyListState()
val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) }
var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query)
@@ -259,6 +260,13 @@ fun SearchScreen(
)
},
actions = {
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
TopBarAnimatedActionButton(
checked = state.isPrecisionSearch,
onCheckedChange = { checked ->
@@ -289,13 +297,6 @@ fun SearchScreen(
activeText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen),
)
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
},
scrollBehavior = scrollBehavior
)
@@ -968,8 +968,11 @@ private fun BookshelfOverlays(
onDismissRequest = { viewModel.dismissOverlay() }
)
val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle()
GroupSelectSheet(
show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
groups = groups,
currentGroupId = 0L,
onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId ->