diff --git a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt index 3a381945c..f2405b6a8 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt @@ -191,10 +191,11 @@ class SearchBooksUseCase( source, keyword, page, - filter = { name, author -> + filter = { name, author, kind -> matchMode == MatchMode.DEFAULT || name.contains(keyword, ignoreCase = true) || - author.contains(keyword, ignoreCase = true) + author.contains(keyword, ignoreCase = true) || + kind?.contains(keyword, ignoreCase = true) == true } ) } @@ -230,19 +231,21 @@ class SearchBooksUseCase( } private val equalBooks = LinkedHashMap() + private val tagsBooks = LinkedHashMap() private val containsBooks = LinkedHashMap() private val otherBooks = LinkedHashMap() var resultLimitReached = false private set val count: Int - get() = equalBooks.size + containsBooks.size + otherBooks.size + get() = equalBooks.size + tagsBooks.size + containsBooks.size + otherBooks.size suspend fun merge(newBooks: List): SearchBookChange { if (newBooks.isEmpty()) return SearchBookChange() val upsertBooks = arrayListOf() val removedBookUrls = linkedSetOf() + val touchedBuckets = linkedSetOf>() newBooks.forEach { newBook -> coroutineContext.ensureActive() val bucket = classifyBucket(newBook) ?: return@forEach @@ -255,33 +258,71 @@ class SearchBooksUseCase( currentBook.addOrigin(newBook.origin) upsertBooks.add(currentBook) } + touchedBuckets.add(bucket) trimSearchBooks()?.let { removed -> removedBookUrls.add(removed.bookUrl) upsertBooks.removeAll { it.bookUrl == removed.bookUrl } } } + // Re-sort touched buckets by origins.size descending + touchedBuckets.forEach { bucket -> + sortBucket(bucket) + } return SearchBookChange(upsertBooks, removedBookUrls.toList()) } + /** + * 将书籍分类到对应的优先级桶中: + * - equalBooks: 书名或作者完全等于搜索词 + * - tagsBooks: 分类标签包含搜索词 + * - containsBooks: 书名或作者包含搜索词(非精确匹配) + * - otherBooks: 其他结果(仅 DEFAULT 模式保留) + */ private fun classifyBucket(book: SearchBook): LinkedHashMap? { return when { book.name.equals(keyword, ignoreCase = true) || book.author.equals(keyword, ignoreCase = true) -> equalBooks + book.kind?.contains(keyword, ignoreCase = true) == true -> { + if (matchMode != MatchMode.DEFAULT) null else tagsBooks + } book.name.contains(keyword, ignoreCase = true) || - book.author.contains( - keyword, - ignoreCase = true - ) -> if (matchMode == MatchMode.EXACT) null else containsBooks - + book.author.contains(keyword, ignoreCase = true) -> { + if (matchMode == MatchMode.EXACT) null else containsBooks + } matchMode != MatchMode.DEFAULT -> null else -> otherBooks } } + /** + * 按 origins.size 降序重新排列桶内元素。 + * 使用 sortedEntries 重建 LinkedHashMap 以保持排序后的迭代顺序。 + */ + private fun sortBucket(bucket: LinkedHashMap) { + if (bucket.size <= 1) return + val sorted = bucket.entries.sortedByDescending { it.value.origins.size } + bucket.clear() + sorted.forEach { (k, v) -> bucket[k] = v } + } + + /** + * 获取排序后的最终结果列表。 + * 每个桶内按来源数量降序排列(多源 = 更可靠),桶间按优先级拼接。 + */ + fun getSortedList(): List { + val sorted = ArrayList(count) + sorted.addAll(equalBooks.values.sortedByDescending { it.origins.size }) + sorted.addAll(tagsBooks.values.sortedByDescending { it.origins.size }) + sorted.addAll(containsBooks.values.sortedByDescending { it.origins.size }) + sorted.addAll(otherBooks.values) + return sorted + } + private fun trimSearchBooks(): SearchBook? { if (count <= MAX_RETAINED_SEARCH_RESULTS) return null resultLimitReached = true return removeLast(otherBooks) + ?: removeLast(tagsBooks) ?: removeLowestOrigin(containsBooks) ?: removeLowestOrigin(equalBooks) } diff --git a/app/src/main/java/io/legado/app/model/webBook/BookList.kt b/app/src/main/java/io/legado/app/model/webBook/BookList.kt index ecc036d93..469d633ab 100644 --- a/app/src/main/java/io/legado/app/model/webBook/BookList.kt +++ b/app/src/main/java/io/legado/app/model/webBook/BookList.kt @@ -40,7 +40,7 @@ object BookList { body: String?, isSearch: Boolean = true, isRedirect: Boolean = false, - filter: ((name: String, author: String) -> Boolean)? = null, + filter: ((name: String, author: String, kind: String?) -> Boolean)? = null, shouldBreak: ((size: Int) -> Boolean)? = null ): ArrayList { body ?: throw NoStackTraceException( @@ -159,7 +159,7 @@ object BookList { baseUrl: String, variable: String?, isRedirect: Boolean, - filter: ((name: String, author: String) -> Boolean)? + filter: ((name: String, author: String, kind: String?) -> Boolean)? ): SearchBook? { val book = Book(variable = variable) book.bookUrl = if (isRedirect) { @@ -181,7 +181,7 @@ object BookList { baseUrl, false ) - if (filter?.invoke(book.name, book.author) == false) { + if (filter?.invoke(book.name, book.author, book.kind) == false) { return null } if (book.name.isNotBlank()) { @@ -198,7 +198,7 @@ object BookList { baseUrl: String, variable: String?, log: Boolean, - filter: ((name: String, author: String) -> Boolean)?, + filter: ((name: String, author: String, kind: String?) -> Boolean)?, ruleName: List, ruleBookUrl: List, ruleAuthor: List, @@ -224,9 +224,6 @@ object BookList { Debug.log(bookSource.bookSourceUrl, "┌获取作者", log) searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor)) Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log) - if (filter?.invoke(searchBook.name, searchBook.author) == false) { - return null - } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取分类", log) try { @@ -236,6 +233,9 @@ object BookList { coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) } + if (filter?.invoke(searchBook.name, searchBook.author, searchBook.kind) == false) { + return null + } coroutineContext.ensureActive() Debug.log(bookSource.bookSourceUrl, "┌获取字数", log) try { diff --git a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt index 6b9346658..5b0b40c2c 100644 --- a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt +++ b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt @@ -51,7 +51,7 @@ object WebBook { bookSource: BookSource, key: String, page: Int? = 1, - filter: ((name: String, author: String) -> Boolean)? = null, + filter: ((name: String, author: String, kind: String?) -> Boolean)? = null, shouldBreak: ((size: Int) -> Boolean)? = null ): ArrayList { val searchUrl = bookSource.searchUrl @@ -481,7 +481,7 @@ object WebBook { coroutineContext.ensureActive() searchBookAwait( bookSource, name, - filter = { fName, fAuthor -> fName == name && fAuthor == author }, + filter = { fName, fAuthor, _ -> fName == name && fAuthor == author }, shouldBreak = { it > 0 } ).firstOrNull()?.let { searchBook -> coroutineContext.ensureActive() diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt index 4451c44ba..2c976016a 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt @@ -275,7 +275,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a val loadWordCount = AppConfig.changeSourceLoadWordCount val resultBooks = WebBook.searchBookAwait( source, name, - filter = { fName, fAuthor -> + filter = { fName, fAuthor, _ -> fName == name && (!checkAuthor || fAuthor.contains(author)) }) resultBooks.forEach { searchBook -> diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index fb6652d29..468129d12 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -16,7 +16,11 @@ class SearchActivity : BaseComposeActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - dispatchInit(intent) + // Only dispatch init on fresh launch, not on config change + // (ViewModel is retained by Koin and already has correct state). + if (savedInstanceState == null) { + dispatchInit(intent) + } } override fun onNewIntent(intent: Intent) { @@ -25,16 +29,6 @@ class SearchActivity : BaseComposeActivity() { dispatchInit(intent) } - override fun onResume() { - super.onResume() - viewModel.onIntent(SearchIntent.ResumeEngine) - } - - override fun onPause() { - viewModel.onIntent(SearchIntent.PauseEngine) - super.onPause() - } - @Composable override fun Content() { SearchScreen( diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt index 49d11ab0d..cdd7cdbaa 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt @@ -8,7 +8,9 @@ import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.model.MatchMode import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf @Stable data class SearchResultItemUi( @@ -27,7 +29,7 @@ data class SearchUiState( val enabledSources: ImmutableList = persistentListOf(), val scopeDisplay: String = "", val scopeDisplayNames: ImmutableList = persistentListOf(), - val selectedScopeSourceUrls: Set = emptySet(), + val selectedScopeSourceUrls: ImmutableSet = persistentSetOf(), val isAllScope: Boolean = true, val isSourceScope: Boolean = false, val matchMode: MatchMode = MatchMode.DEFAULT, @@ -36,7 +38,7 @@ data class SearchUiState( val hasMore: Boolean = true, val processedSources: Int = 0, val totalSources: Int = 0, - val selectedSourceTypes: Set = emptySet(), + val selectedSourceTypes: ImmutableSet = persistentSetOf(), val showScopeSheet: Boolean = false, val showSettingsSheet: Boolean = false, val showClearHistoryDialog: Boolean = false, @@ -63,7 +65,7 @@ data class SearchEmptyScopeAction( sealed interface SearchIntent { data class Initialize(val key: String?, val scopeRaw: String?) : SearchIntent - data class UpdateQuery(val query: String) : SearchIntent + data class UpdateQuery(val query: String, val showSuggestions: Boolean = true) : SearchIntent data object SubmitSearch : SearchIntent data object LoadMore : SearchIntent data object StopSearch : SearchIntent @@ -84,6 +86,7 @@ sealed interface SearchIntent { data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent data class ToggleSourceType(val type: Int) : SearchIntent + data object ClearAllSourceTypes : SearchIntent data object SelectAllScope : SearchIntent data class ToggleScopeGroup(val groupName: String) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt index fc953da39..4d21deb37 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt @@ -120,6 +120,7 @@ data class SearchScope(private var scope: String) { this.scope = stringBuilder.toString() } stateLiveData.postValue(this.scope) + save() } /** diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 664c45515..a8114753c 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -108,6 +108,7 @@ fun SearchScreen( var previewBook by remember { mutableStateOf(null) } var previewSharedCoverKey by remember { mutableStateOf(null) } val listState = rememberLazyListState() + val groupedListState = rememberLazyListState() val lifecycleOwner = LocalLifecycleOwner.current var queryInput by rememberSaveable { mutableStateOf(state.query) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf(null) } @@ -127,6 +128,14 @@ fun SearchScreen( } } + val shouldLoadMoreGrouped by remember { + derivedStateOf { + val totalCount = groupedListState.layoutInfo.totalItemsCount + val lastVisible = groupedListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + totalCount > 0 && lastVisible >= totalCount - 3 + } + } + LaunchedEffect(state.query) { if (state.query != queryInput) { queryInput = state.query @@ -150,20 +159,19 @@ fun SearchScreen( LaunchedEffect( shouldLoadMore, + shouldLoadMoreGrouped, state.isSearching, state.hasMore, state.isManualStop, state.showSuggestions, isSourceGroupedMode, ) { - if ( - shouldLoadMore && - !state.isSearching && + val readyToLoad = !state.isSearching && state.hasMore && !state.isManualStop && - !state.showSuggestions && - !isSourceGroupedMode - ) { + !state.showSuggestions + val nearEnd = if (isSourceGroupedMode) shouldLoadMoreGrouped else shouldLoadMore + if (readyToLoad && nearEnd) { viewModel.onIntent(SearchIntent.LoadMore) } } @@ -416,6 +424,7 @@ fun SearchScreen( ) { isSourceGrouped -> if (isSourceGrouped) { LazyColumn( + state = groupedListState, modifier = Modifier.fillMaxSize(), contentPadding = adaptiveContentPaddingOnlyVertical( top = 48.dp, @@ -477,11 +486,11 @@ fun SearchScreen( ) { itemsIndexed( items = state.results, - key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } + key = { _, item -> "${item.book.origin}:${item.book.bookUrl}" } ) { index, item -> val sharedCoverKey = bookCoverSharedElementKey( item.book.bookUrl, - "search:${item.book.origin}:$index" + "search:${item.book.origin}" ) SearchBookListItem( book = item.book, @@ -636,7 +645,7 @@ fun SearchScreen( AppIcon(Icons.Default.Layers, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) AppText( - text = "搜索类型", + text = stringResource(R.string.search_type), style = LegadoTheme.typography.titleSmall ) } @@ -679,9 +688,11 @@ fun SearchScreen( } } + val resultsByBookUrl = remember(state.results) { + state.results.associateBy { it.book.bookUrl } + } val previewShelfState = previewBook?.let { book -> - state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState - ?: BookShelfState.NOT_IN_SHELF + resultsByBookUrl[book.bookUrl]?.shelfState ?: BookShelfState.NOT_IN_SHELF } SearchBookPreviewSheet( data = previewBook, @@ -864,7 +875,7 @@ private fun SearchResultFooter( hasMore -> { Text( - text = stringResource(R.string.search_empty), + text = stringResource(R.string.search_has_more), color = LegadoTheme.colorScheme.onSurfaceVariant, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 028dc044e..9dc6fea46 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -20,7 +20,9 @@ import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -107,7 +109,7 @@ class SearchViewModel( fun onIntent(intent: SearchIntent) { when (intent) { is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw) - is SearchIntent.UpdateQuery -> updateQuery(intent.query, showSuggestions = true) + is SearchIntent.UpdateQuery -> updateQuery(intent.query, intent.showSuggestions) SearchIntent.SubmitSearch -> submitSearch() SearchIntent.LoadMore -> loadMore() SearchIntent.StopSearch -> stopSearch() @@ -185,15 +187,20 @@ class SearchViewModel( _uiState.update { state -> val current = state.selectedSourceTypes val next = if (current.contains(intent.type)) { - current - intent.type + (current - intent.type).toImmutableSet() } else { - current + intent.type + (current + intent.type).toImmutableSet() } state.copy(selectedSourceTypes = next) } restartCommittedSearchIfNeeded() } + SearchIntent.ClearAllSourceTypes -> { + _uiState.update { it.copy(selectedSourceTypes = persistentSetOf()) } + restartCommittedSearchIfNeeded() + } + SearchIntent.SelectAllScope -> { val oldScope = searchScope.toString() searchScope.update("") @@ -310,6 +317,10 @@ class SearchViewModel( } syncScopeState() + // Always reset search state so the screen starts clean, + // regardless of whether the ViewModel was retained by navigation. + clearSearchResults() + val initKey = key?.trim().orEmpty() if (initKey.isNotEmpty()) { updateQuery(initKey, showSuggestions = false) @@ -413,6 +424,11 @@ class SearchViewModel( updateQuery(keyword, showSuggestions = false) + // Cancel the old search job BEFORE clearing results to prevent + // stale Progress events from re-inserting books into the map. + searchJob?.cancel() + searchJob = null + currentSearchPage = 1 searchResultBooks.clear() _uiState.update { @@ -546,6 +562,7 @@ class SearchViewModel( private fun clearSearchResults() { stopSearch(manualStop = true) searchResultBooks.clear() + queryFlow.value = "" _uiState.update { it.copy( query = "", @@ -646,7 +663,7 @@ class SearchViewModel( it.copy( scopeDisplay = searchScope.display, scopeDisplayNames = searchScope.displayNames.toImmutableList(), - selectedScopeSourceUrls = searchScope.sourceUrls.toSet(), + selectedScopeSourceUrls = searchScope.sourceUrls.toImmutableSet(), isAllScope = searchScope.isAll(), isSourceScope = searchScope.isSource(), ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2cb56a3c1..b01649198 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1648,6 +1648,8 @@ · 进度 %1$d/%2$d %1$s分组搜索结果为空,是否关闭精准搜索? %1$s分组搜索结果为空,是否切换到全部分组? + 搜索类型 + 上拉或点击按钮加载更多 列表布局 来源分组 下载与缓存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 931c68c54..ef27356fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1684,6 +1684,8 @@ · Progress %1$d/%2$d %1$s group search returned no results. Disable precise search? %1$s group search returned no results. Switch to all groups? + Search type + Pull up or tap button to load more List layout Source grouped Download & Cache