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

换源界面新增多选分组和书源功能
This commit is contained in:
HapeLee committed 2026-05-11 03:28:12 +08:00
1 parent 920d4058e7
commit 45e678b1ae
13 files changed
+508 -210

No files matched your search

+2 -1
View File
@@ -49,7 +49,8 @@
"PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)", "PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)",
"Bash(npx skills *)", "Bash(npx skills *)",
"Bash(npx *)", "Bash(npx *)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)" "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)",
"Bash(cmd /c \"gradlew.bat :app:compileAppDebugKotlin\")"
] ]
} }
} }
@@ -172,6 +172,16 @@ interface BookSourceDao {
) )
fun getEnabledPartByGroup(group: String): List<BookSourcePart> 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( @Query(
"""select * from book_sources """select * from book_sources
where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc""" where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc"""
@@ -11,6 +11,10 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) {
return bookGroupDao.flowAll() return bookGroupDao.flowAll()
} }
fun flowSelect(): Flow<List<BookGroup>> {
return bookGroupDao.flowSelect()
}
fun flowShow(): Flow<List<BookGroup>> { fun flowShow(): Flow<List<BookGroup>> {
return bookGroupDao.show.asFlow() return bookGroupDao.show.asFlow()
} }
@@ -1,14 +1,42 @@
package io.legado.app.ui.book.changesource package io.legado.app.ui.book.changesource
import android.app.Application 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.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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class ChangeBookSourceComposeViewModel(application: Application) : class ChangeBookSourceComposeViewModel(
ChangeBookSourceViewModel(application) { application: Application,
private val searchRepository: SearchRepository,
) : ChangeBookSourceViewModel(application) {
val searchGroup: String val enabledGroups = searchRepository.enabledGroups
get() = ChangeSourceConfig.searchGroup 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 val checkAuthor: Boolean
get() = ChangeSourceConfig.checkAuthor get() = ChangeSourceConfig.checkAuthor
@@ -22,12 +50,6 @@ class ChangeBookSourceComposeViewModel(application: Application) :
val loadWordCount: Boolean val loadWordCount: Boolean
get() = ChangeSourceConfig.loadWordCount get() = ChangeSourceConfig.loadWordCount
fun onSearchGroupSelected(group: String) {
if (ChangeSourceConfig.searchGroup == group) return
ChangeSourceConfig.searchGroup = group
if (refresh()) startSearch()
}
fun onCheckAuthorChange(enabled: Boolean) { fun onCheckAuthorChange(enabled: Boolean) {
if (ChangeSourceConfig.checkAuthor == enabled) return if (ChangeSourceConfig.checkAuthor == enabled) return
ChangeSourceConfig.checkAuthor = enabled ChangeSourceConfig.checkAuthor = enabled
@@ -62,4 +84,60 @@ class ChangeBookSourceComposeViewModel(application: Application) :
val currentScore = ObservableSourceConfig.getBookScore(searchBook) val currentScore = ObservableSourceConfig.getBookScore(searchBook)
setBookScore(searchBook, if (currentScore > 0) 0 else 1) 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.channels.awaitClose
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -97,6 +98,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
.thenBy { it.originOrder } .thenBy { it.originOrder }
} }
private var task: Job? = null private var task: Job? = null
private var isPaused = false
private var wasSearching = false
val bookMap = ConcurrentHashMap<String, Book>() val bookMap = ConcurrentHashMap<String, Book>()
val searchDataFlow = callbackFlow { 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()) { if (searchBooks.isEmpty()) {
getDbSearchBooks().let {
searchBooks.addAll(it)
}
}
trySend(arrayOf(searchBooks))
if (searchBooks.isEmpty() && !_isSearching.value) {
startSearch() startSearch()
} }
@@ -207,18 +211,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
bookMap.clear() bookMap.clear()
tocMapChapterCount = 0 tocMapChapterCount = 0
_changeSourceProgress.value = 0 to "" _changeSourceProgress.value = 0 to ""
val searchGroup = AppConfig.searchGroup bookSourceParts.addAll(io.legado.app.ui.book.search.SearchScope(ChangeSourceConfig.searchScope).getBookSourceParts())
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)
}
}
initSearchPool() initSearchPool()
search() search()
} }
@@ -250,6 +243,9 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchStateData.postValue(true) searchStateData.postValue(true)
_isSearching.value = true _isSearching.value = true
}.mapParallel(threadCount) { }.mapParallel(threadCount) {
while (isPaused) {
kotlinx.coroutines.delay(100)
}
try { try {
withTimeout(60000L) { withTimeout(60000L) {
search(it) search(it)
@@ -465,6 +461,17 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchPool?.close() searchPool?.close()
searchStateData.postValue(false) searchStateData.postValue(false)
_isSearching.value = false _isSearching.value = false
wasSearching = false
}
fun pause() {
isPaused = true
wasSearching = _isSearching.value
}
fun resume() {
isPaused = false
wasSearching = false
} }
fun getToc( fun getToc(
@@ -1,6 +1,7 @@
package io.legado.app.ui.book.changesource package io.legado.app.ui.book.changesource
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.ui.config.prefDelegate import io.legado.app.ui.config.prefDelegate
object ChangeSourceConfig { object ChangeSourceConfig {
@@ -10,6 +11,11 @@ object ChangeSourceConfig {
defaultValue = "" defaultValue = ""
) )
var searchScope by prefDelegate(
key = "changeSourceSearchScope",
defaultValue = ""
)
var checkAuthor by prefDelegate( var checkAuthor by prefDelegate(
key = PreferKey.changeSourceCheckAuthor, key = PreferKey.changeSourceCheckAuthor,
defaultValue = false defaultValue = false
@@ -29,4 +35,61 @@ object ChangeSourceConfig {
key = PreferKey.changeSourceLoadWordCount, key = PreferKey.changeSourceLoadWordCount,
defaultValue = false 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, onDismissRequest: () -> Unit,
onConfirm: (ChangeSourceMigrationOptions) -> Unit, onConfirm: (ChangeSourceMigrationOptions) -> Unit,
) { ) {
var migrateReadingProgress by rememberSaveable(show) { mutableStateOf(true) } var migrateReadingProgress by rememberSaveable(show) {
var migrateGroup by rememberSaveable(show) { mutableStateOf(true) } mutableStateOf(ChangeSourceConfig.migrateReadingProgress)
var migrateCover by rememberSaveable(show) { mutableStateOf(true) } }
var migrateCategory by rememberSaveable(show) { mutableStateOf(true) } var migrateGroup by rememberSaveable(show) {
var migrateRemark by rememberSaveable(show) { mutableStateOf(true) } mutableStateOf(ChangeSourceConfig.migrateGroup)
var migrateReadConfig by rememberSaveable(show) { mutableStateOf(true) } }
var deleteDownloadedChapters by rememberSaveable(show) { mutableStateOf(false) } 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( AppModalBottomSheet(
show = show, show = show,
@@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.legado.app.R import io.legado.app.R
@@ -282,12 +283,16 @@ private fun BookInfoScreenContent(
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onSelect = { onIntent(BookInfoIntent.SelectCover(it)) }, onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
) )
BookInfoSheet.GroupPicker -> GroupSelectSheet( BookInfoSheet.GroupPicker -> {
show = currentSheet == BookInfoSheet.GroupPicker, val groups by koinInject<io.legado.app.data.repository.BookGroupRepository>().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
currentGroupId = state.book?.group ?: 0L, GroupSelectSheet(
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, show = currentSheet == BookInfoSheet.GroupPicker,
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) }, groups = groups,
) currentGroupId = state.book?.group ?: 0L,
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
)
}
BookInfoSheet.SourcePicker -> state.book?.let { book -> BookInfoSheet.SourcePicker -> state.book?.let { book ->
ChangeSourceSheet( ChangeSourceSheet(
show = currentSheet == BookInfoSheet.SourcePicker, show = currentSheet == BookInfoSheet.SourcePicker,
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Edit
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MoreVert 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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -45,19 +47,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions 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.changecover.ChangeCoverViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel 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.changesource.ChangeSourceMigrationOptionsSheet
import io.legado.app.ui.book.group.GroupEditSheet import io.legado.app.ui.book.group.GroupEditSheet
import io.legado.app.ui.book.source.edit.BookSourceEditActivity 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.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.AppTextField 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.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton 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.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.modalBottomSheet.AppModalBottomSheet 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.ui.widget.components.text.AppText
import io.legado.app.utils.StartActivityContract import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
@@ -118,11 +125,11 @@ fun WebFileSheet(
@Composable @Composable
fun GroupSelectSheet( fun GroupSelectSheet(
show: Boolean, show: Boolean,
groups: List<BookGroup>,
currentGroupId: Long, currentGroupId: Long,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onConfirm: (Long) -> Unit, onConfirm: (Long) -> Unit,
) { ) {
val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) } var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) }
var editingGroup by remember { mutableStateOf<BookGroup?>(null) } var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
@@ -138,7 +145,7 @@ fun GroupSelectSheet(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(groups, key = { it.groupId }) { group -> items(groups, key = { it.groupId }) { group ->
val isSelected = selectedGroupId and group.groupId > 0 val isSelected = (selectedGroupId and group.groupId) != 0L
SelectionItemCard( SelectionItemCard(
title = group.groupName, title = group.groupName,
isSelected = isSelected, isSelected = isSelected,
@@ -194,7 +201,7 @@ fun ChangeCoverSheet(
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"), 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() val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
LaunchedEffect(name, author) { LaunchedEffect(name, author) {
@@ -244,22 +251,25 @@ fun ChangeSourceSheet(
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
) { ) {
val context = LocalContext.current 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 isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val progress by viewModel.changeSourceProgress.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("") } var searchQuery by rememberSaveable { mutableStateOf("") }
val selectedGroup = viewModel.searchGroup
val checkAuthor = viewModel.checkAuthor val checkAuthor = viewModel.checkAuthor
val loadInfo = viewModel.loadInfo val loadInfo = viewModel.loadInfo
val loadToc = viewModel.loadToc val loadToc = viewModel.loadToc
val loadWordCount = viewModel.loadWordCount val loadWordCount = viewModel.loadWordCount
var actionBook by remember { mutableStateOf<SearchBook?>(null) } var actionBook by remember { mutableStateOf<SearchBook?>(null) }
var mismatchBook 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 loadingAction by remember { mutableStateOf(false) }
var showOptionsMenu by rememberSaveable { 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 bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
@@ -270,6 +280,21 @@ fun ChangeSourceSheet(
LaunchedEffect(oldBook.bookUrl) { LaunchedEffect(oldBook.bookUrl) {
viewModel.initData(oldBook.name, oldBook.author, oldBook, false) 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) { DisposableEffect(oldBook.bookUrl) {
onDispose { onDispose {
viewModel.stopSearch() viewModel.stopSearch()
@@ -323,10 +348,17 @@ fun ChangeSourceSheet(
dismiss() dismiss()
} }
) )
RoundDropdownMenuItem(
text = stringResource(R.string.book_source_manage),
onClick = {
context.startActivity<BookSourceActivity>()
dismiss()
}
)
} }
} }
MediumIconButton( MediumIconButton(
onClick = { context.startActivity<BookSourceActivity>() }, onClick = { showMigrationOptions = true },
imageVector = Icons.Outlined.Settings imageVector = Icons.Outlined.Settings
) )
} }
@@ -337,35 +369,10 @@ fun ChangeSourceSheet(
onClick = { viewModel.startOrStopSearch() }, onClick = { viewModel.startOrStopSearch() },
imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
) )
Box { MediumIconButton(
MediumIconButton( onClick = { showFilterSheet = true },
onClick = { showFilterMenu = true }, imageVector = Icons.Default.FilterList
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()
}
)
}
}
}
} }
} }
) { ) {
@@ -389,95 +396,116 @@ fun ChangeSourceSheet(
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items, key = { it.bookUrl + it.origin }) { item -> if (items.isEmpty()) {
val bookScore by remember(item.origin, item.name, item.author) { Box(
viewModel.bookScoreFlow(item) modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
}.collectAsStateWithLifecycle() contentAlignment = Alignment.Center
SelectionItemCard( ) {
title = item.originName, EmptyMessage(
containerColor = LegadoTheme.colorScheme.onSheetContent, message = stringResource(R.string.search_empty)
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()
}
)
}
) )
} }
} 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)) Spacer(modifier = Modifier.height(16.dp))
} }
@@ -485,19 +513,24 @@ fun ChangeSourceSheet(
val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace -> val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
loadingAction = true loadingAction = true
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook() val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
viewModel.getToc(book, { toc, source -> viewModel.getToc(
loadingAction = false book,
if (replace) { onSuccess = { toc, source ->
pendingMigration = PendingSourceMigration(source, book, toc) loadingAction = false
} else { if (replace) {
onAddAsNew(book, toc) onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions())
context.toastOnUi(bookAddedToShelfText) 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( AppAlertDialog(
@@ -531,30 +564,103 @@ fun ChangeSourceSheet(
} }
} }
) )
val migration = pendingMigration
ChangeSourceMigrationOptionsSheet( ChangeSourceMigrationOptionsSheet(
show = migration != null, show = showMigrationOptions,
title = "换源选项", title = "换源选项",
subtitle = migration?.let { onDismissRequest = { showMigrationOptions = false },
val sameNameAuthor = oldBook.isSameNameAuthor(it.book)
if (sameNameAuthor && oldBook.origin != it.book.origin) {
"检测到书名、作者相同但书源不同,可选择本次要迁移的数据。"
} else {
"选择本次替换当前书籍时要迁移的数据。"
}
},
onDismissRequest = { pendingMigration = null },
onConfirm = { options -> onConfirm = { options ->
val pending = pendingMigration ?: return@ChangeSourceMigrationOptionsSheet ChangeSourceConfig.setMigrationOptions(options)
onReplace(pending.source, pending.book, pending.toc, options) showMigrationOptions = false
pendingMigration = null
onDismissRequest()
} }
) )
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.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository 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.domain.usecase.ClearBookCacheUseCase
import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoBooksDirException
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
@@ -68,9 +69,12 @@ class BookInfoViewModel(
private val remoteBookRepository: RemoteBookRepository, private val remoteBookRepository: RemoteBookRepository,
private val readRecordRepository: ReadRecordRepository, private val readRecordRepository: ReadRecordRepository,
private val changeBookSourceUseCase: ChangeBookSourceUseCase, private val changeBookSourceUseCase: ChangeBookSourceUseCase,
private val clearBookCacheUseCase: ClearBookCacheUseCase private val clearBookCacheUseCase: ClearBookCacheUseCase,
private val bookGroupRepository: BookGroupRepository,
) : BaseViewModel(application) { ) : BaseViewModel(application) {
val allGroups = bookGroupRepository.flowAll()
private val _uiState = MutableStateFlow(BookInfoUiState()) private val _uiState = MutableStateFlow(BookInfoUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -72,6 +72,7 @@ import io.legado.app.R
import io.legado.app.constant.IntentAction import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book 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.BookSource
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
@@ -1056,6 +1057,7 @@ private fun BookshelfManageScreen(
GroupSelectSheet( GroupSelectSheet(
show = showGroupSelectSheet, show = showGroupSelectSheet,
groups = state.groupList,
currentGroupId = groupPickerCurrentGroupId, currentGroupId = groupPickerCurrentGroupId,
onDismissRequest = { showGroupSelectSheet = false }, onDismissRequest = { showGroupSelectSheet = false },
onConfirm = { groupId -> onConfirm = { groupId ->
@@ -39,6 +39,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@@ -103,7 +104,7 @@ fun SearchScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) } 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) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
val showSuggestionPanel = state.showSuggestions val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query) val latestQuery by rememberUpdatedState(state.query)
@@ -259,6 +260,13 @@ fun SearchScreen(
) )
}, },
actions = { actions = {
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
TopBarAnimatedActionButton( TopBarAnimatedActionButton(
checked = state.isPrecisionSearch, checked = state.isPrecisionSearch,
onCheckedChange = { checked -> onCheckedChange = { checked ->
@@ -289,13 +297,6 @@ fun SearchScreen(
activeText = stringResource(R.string.screen), activeText = stringResource(R.string.screen),
inactiveText = 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 scrollBehavior = scrollBehavior
) )
@@ -968,8 +968,11 @@ private fun BookshelfOverlays(
onDismissRequest = { viewModel.dismissOverlay() } onDismissRequest = { viewModel.dismissOverlay() }
) )
val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle()
GroupSelectSheet( GroupSelectSheet(
show = activeOverlay == BookshelfOverlay.GroupSelectSheet, show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
groups = groups,
currentGroupId = 0L, currentGroupId = 0L,
onDismissRequest = { viewModel.dismissOverlay() }, onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId -> onConfirm = { groupId ->