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 a8114753c..cf98fe7ad 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 @@ -509,7 +509,8 @@ fun SearchScreen( }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey + sharedCoverKey = sharedCoverKey, + sourceCount = item.book.origins.size, ) } 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 71024211f..384912bc0 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 @@ -84,10 +84,11 @@ class SearchViewModel( private val bookshelfKeys = MutableStateFlow>(emptySet()) private val searchScope = SearchScope(AppConfig.searchScope) private val searchControl = BookSearchControl() - private val searchResultBooks = LinkedHashMap() + private val searchResultBooks = LinkedHashMap() private var searchJob: Job? = null private var currentSearchPage = 1 + private var resultCountBeforeCurrentPage = 0 private var wasSearching = false init { @@ -483,6 +484,7 @@ class SearchViewModel( private fun startSearch(keyword: String, page: Int) { searchJob?.cancel() searchControl.resume() + resultCountBeforeCurrentPage = searchResultBooks.size wasSearching = true searchJob = viewModelScope.launch { try { @@ -517,10 +519,8 @@ class SearchViewModel( } is SearchRunEvent.Progress -> { - event.removedBookUrls.forEach { searchResultBooks.remove(it) } - event.upsertBooks.forEach { book -> - searchResultBooks[book.bookUrl] = book - } + removeSearchResults(event.removedBookUrls) + mergeSearchResults(event.upsertBooks) _uiState.update { it.copy( results = buildSearchResultItems( @@ -542,9 +542,16 @@ class SearchViewModel( } else { null } + val hasNewPageResults = searchResultBooks.size > resultCountBeforeCurrentPage + val exactSearchShouldStopAfterFirstPage = + state.matchMode == MatchMode.EXACT && + currentSearchPage == 1 && + searchResultBooks.size <= EXACT_SEARCH_SINGLE_PAGE_RESULT_THRESHOLD state.copy( isSearching = false, - hasMore = event.hasMore, + hasMore = event.hasMore && + hasNewPageResults && + !exactSearchShouldStopAfterFirstPage, emptyScopeAction = emptyAction, ) } @@ -697,7 +704,9 @@ class SearchViewModel( private fun buildSearchResultItems( shelf: Set, ): List { - return searchResultBooks.values.toList().toSearchResultItems(shelf) + return searchResultBooks.values + .sortedWithSearchPriority(_uiState.value.committedQuery, _uiState.value.matchMode) + .toSearchResultItems(shelf) } private fun List.withShelfState( @@ -749,4 +758,57 @@ class SearchViewModel( private fun emitEffect(effect: SearchEffect) { _effects.tryEmit(effect) } + + private fun mergeSearchResults(books: List) { + books.forEach { book -> + val key = SearchResultKey(book.name, book.author) + val currentBook = searchResultBooks[key] + if (currentBook == null) { + searchResultBooks[key] = book + } else { + book.origins.forEach { origin -> currentBook.addOrigin(origin) } + } + } + } + + private fun removeSearchResults(bookUrls: List) { + if (bookUrls.isEmpty()) return + val removedUrls = bookUrls.toHashSet() + searchResultBooks.entries.removeAll { (_, book) -> book.bookUrl in removedUrls } + } + + private fun Collection.sortedWithSearchPriority( + keyword: String, + matchMode: MatchMode, + ): List { + val equalBooks = arrayListOf() + val tagsBooks = arrayListOf() + val containsBooks = arrayListOf() + val otherBooks = arrayListOf() + forEach { book -> + when { + book.name.equals(keyword, ignoreCase = true) || + book.author.equals(keyword, ignoreCase = true) -> equalBooks.add(book) + book.kind?.contains(keyword, ignoreCase = true) == true -> tagsBooks.add(book) + book.name.contains(keyword, ignoreCase = true) || + book.author.contains(keyword, ignoreCase = true) -> containsBooks.add(book) + matchMode == MatchMode.DEFAULT -> otherBooks.add(book) + } + } + return buildList(size) { + addAll(equalBooks.sortedByDescending { it.origins.size }) + addAll(tagsBooks.sortedByDescending { it.origins.size }) + addAll(containsBooks.sortedByDescending { it.origins.size }) + addAll(otherBooks) + } + } + + private data class SearchResultKey( + val name: String, + val author: String, + ) + + private companion object { + const val EXACT_SEARCH_SINGLE_PAGE_RESULT_THRESHOLD = 3 + } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt index b1e79f605..3321af955 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt @@ -28,9 +28,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.legado.app.R import io.legado.app.data.entities.SearchBook import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.theme.LegadoTheme @@ -53,6 +55,7 @@ fun SearchBookListItem( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, + sourceCount: Int? = null, ) { Row( modifier = modifier @@ -106,12 +109,25 @@ fun SearchBookListItem( .weight(1f) .align(Alignment.CenterVertically) ) { - AppText( - text = book.name, - style = LegadoTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + AppText( + text = book.name, + modifier = Modifier.weight(1f), + style = LegadoTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + sourceCount?.let { count -> + Spacer(modifier = Modifier.width(8.dp)) + TextCard( + text = stringResource(R.string.search_book_source_count, count), + cornerRadius = 4.dp, + horizontalPadding = 4.dp, + verticalPadding = 2.dp + ) + } + } Row { AppText( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dfe919370..ceb804474 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1724,6 +1724,7 @@ %1$s分组搜索结果为空,是否切换到全部分组? 搜索类型 上拉或点击按钮加载更多 + %1$d 列表布局 来源分组 下载与缓存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d470697f2..3dccbfcda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1760,6 +1760,7 @@ %1$s group search returned no results. Switch to all groups? Search type Pull up or tap button to load more + %1$d List layout Source grouped Download & Cache