Просмотр исходного кода

换源时,不会再因为打开浏览器而导致重走换源流程
换源界面新增多选分组和书源功能

HapeLee 6 месяцев назад
Родитель
Сommit
45e678b1ae

+ 2 - 1
.claude/settings.local.json

@@ -49,7 +49,8 @@
       "PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)",
       "Bash(npx skills *)",
       "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\")"
     ]
   }
 }

+ 10 - 0
app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt

@@ -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"""

+ 4 - 0
app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt

@@ -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()
     }

+ 88 - 10
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -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()
+    }
 }

+ 24 - 17
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt

@@ -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()) {
+        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(

+ 63 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt

@@ -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
+    }
 }

+ 21 - 7
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt

@@ -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,

+ 11 - 6
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -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,

+ 259 - 153
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -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,94 +396,115 @@ 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
+
+        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
                             )
-                            item.chapterWordCountText?.takeIf { loadWordCount }?.let {
+                        },
+                        supportingContent = {
+                            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                                 AppText(
-                                    text = it,
-                                    style = LegadoTheme.typography.labelSmallEmphasized,
-                                    color = LegadoTheme.colorScheme.primary
+                                    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()
+                        },
+                        isSelected = item.bookUrl == oldBook.bookUrl,
+                        onToggleSelection = {
+                            if (item.bookUrl != oldBook.bookUrl) {
+                                if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook =
+                                    item
                             }
-                        )
-                        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)
+                        },
+                        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()
                                 }
-                                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>,
-)

+ 5 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -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()
 

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -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 ->

+ 9 - 8
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -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
                 )

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -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 ->