From df2aaff0e2d905d0ed429e234651d7415cecda81 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Wed, 27 May 2026 00:45:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=90=9C=E7=B4=A0=E5=88=86?= =?UTF-8?q?=E6=BA=90=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/domain/usecase/SearchBooksUseCase.kt | 23 +- .../app/ui/book/search/ScopeSelectSheet.kt | 9 +- .../app/ui/book/search/SearchActivity.kt | 2 +- .../app/ui/book/search/SearchContract.kt | 37 +- .../legado/app/ui/book/search/SearchScreen.kt | 419 +++++++++++++----- .../app/ui/book/search/SearchViewModel.kt | 174 ++++++-- .../book/searchContent/SearchContentScreen.kt | 27 +- 7 files changed, 522 insertions(+), 169 deletions(-) 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 60a10286b..3a381945c 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 @@ -6,6 +6,7 @@ import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.model.MatchMode import io.legado.app.exception.NoStackTraceException import io.legado.app.model.webBook.WebBook import kotlinx.coroutines.CancellationException @@ -30,7 +31,7 @@ data class BookSearchRequest( val keyword: String, val page: Int, val scope: BookSearchScope, - val precision: Boolean, + val matchMode: MatchMode, val concurrency: Int, val types: Set? = null, ) @@ -103,7 +104,7 @@ class SearchBooksUseCase( throw NoStackTraceException("可搜索书源为空") } - val merger = SearchResultMerger(keyword, request.precision) + val merger = SearchResultMerger(keyword, request.matchMode) val concurrency = request.concurrency.coerceAtLeast(1) var hasMore = false var processedSources = 0 @@ -116,7 +117,7 @@ class SearchBooksUseCase( .flatMapMerge(concurrency) { searchableSource -> flow { control.awaitResumed() - emit(searchSource(searchableSource, keyword, request.page, request.precision)) + emit(searchSource(searchableSource, keyword, request.page, request.matchMode)) }.flowOn(Dispatchers.IO) } .collect { result -> @@ -177,7 +178,7 @@ class SearchBooksUseCase( searchableSource: SearchableSource, keyword: String, page: Int, - precision: Boolean, + matchMode: MatchMode, ): SourceSearchResult { return try { val source = searchableSource.source @@ -191,7 +192,7 @@ class SearchBooksUseCase( keyword, page, filter = { name, author -> - !precision || + matchMode == MatchMode.DEFAULT || name.contains(keyword, ignoreCase = true) || author.contains(keyword, ignoreCase = true) } @@ -222,7 +223,7 @@ class SearchBooksUseCase( private class SearchResultMerger( private val keyword: String, - private val precision: Boolean, + private val matchMode: MatchMode, ) { private companion object { const val MAX_RETAINED_SEARCH_RESULTS = 1000 @@ -267,9 +268,13 @@ class SearchBooksUseCase( book.name.equals(keyword, ignoreCase = true) || book.author.equals(keyword, ignoreCase = true) -> equalBooks book.name.contains(keyword, ignoreCase = true) || - book.author.contains(keyword, ignoreCase = true) -> containsBooks - !precision -> otherBooks - else -> null + book.author.contains( + keyword, + ignoreCase = true + ) -> if (matchMode == MatchMode.EXACT) null else containsBooks + + matchMode != MatchMode.DEFAULT -> null + else -> otherBooks } } diff --git a/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt b/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt index 7c925ca4e..5ce44fe51 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt @@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -27,8 +25,9 @@ import io.legado.app.R import io.legado.app.data.entities.BookSourcePart import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.SearchBar -import io.legado.app.ui.widget.components.button.MediumIconButton +import io.legado.app.ui.widget.components.button.MediumPlainButton import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.tabRow.AppTabRow @@ -68,9 +67,9 @@ fun ScopeSelectSheet( title = title, endAction = onConfirm?.let { { - MediumIconButton( + MediumPlainButton( onClick = it, - imageVector = Icons.Default.Check + icon = AppIcons.Settings ) } } 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 01d004271..fb6652d29 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 @@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() { }, onOpenSourceManage = { startActivity() - } + }, ) } 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 429bef7aa..434a4fee0 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 @@ -5,7 +5,10 @@ import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword 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.persistentListOf @Stable data class SearchResultItemUi( @@ -17,17 +20,17 @@ data class SearchResultItemUi( data class SearchUiState( val query: String = "", val committedQuery: String = "", - val results: List = emptyList(), - val history: List = emptyList(), - val bookshelfHints: List = emptyList(), - val enabledGroups: List = emptyList(), - val enabledSources: List = emptyList(), + val results: ImmutableList = persistentListOf(), + val history: ImmutableList = persistentListOf(), + val bookshelfHints: ImmutableList = persistentListOf(), + val enabledGroups: ImmutableList = persistentListOf(), + val enabledSources: ImmutableList = persistentListOf(), val scopeDisplay: String = "", - val scopeDisplayNames: List = emptyList(), + val scopeDisplayNames: ImmutableList = persistentListOf(), val selectedScopeSourceUrls: Set = emptySet(), val isAllScope: Boolean = true, val isSourceScope: Boolean = false, - val isPrecisionSearch: Boolean = false, + val matchMode: MatchMode = MatchMode.DEFAULT, val isSearching: Boolean = false, val isManualStop: Boolean = false, val hasMore: Boolean = true, @@ -35,17 +38,24 @@ data class SearchUiState( val totalSources: Int = 0, val selectedSourceTypes: Set = emptySet(), val showScopeSheet: Boolean = false, - val showTypeSheet: Boolean = false, + val showSettingsSheet: Boolean = false, val showClearHistoryDialog: Boolean = false, val showSuggestions: Boolean = true, val emptyScopeAction: SearchEmptyScopeAction? = null, val savedScrollIndex: Int = 0, val savedScrollOffset: Int = 0, + val expandedSourceUrl: String? = null, + val expandedSourceName: String? = null, + val expandedSourceBooks: ImmutableList = persistentListOf(), + val expandedSourceLoading: Boolean = false, + val expandedSourceEnd: Boolean = false, + val expandedSourceError: String? = null, + val expandedSourcePage: Int = 1, ) data class SearchEmptyScopeAction( val scopeDisplay: String, - val wasPrecisionSearch: Boolean, + val wasMatchMode: MatchMode, ) sealed interface SearchIntent { @@ -60,17 +70,22 @@ sealed interface SearchIntent { data class UseHistoryKeyword(val keyword: String) : SearchIntent data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent + data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent + data object DismissExpandedSource : SearchIntent + data object LoadMoreExpandedSource : SearchIntent + data class OpenExpandedSourceBook(val book: SearchBook, val sharedCoverKey: String?) : + SearchIntent data class DeleteHistory(val item: SearchKeyword) : SearchIntent data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent data object ConfirmClearHistory : SearchIntent data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent - data class SetTypeSheetVisible(val visible: Boolean) : SearchIntent + data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent data class ToggleSourceType(val type: Int) : SearchIntent data object SelectAllScope : SearchIntent data class ToggleScopeGroup(val groupName: String) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class RemoveScopeItem(val scopeName: String) : SearchIntent - data class TogglePrecision(val enabled: Boolean) : SearchIntent + data class SetMatchMode(val mode: MatchMode) : SearchIntent data object ConfirmEmptyScopeAction : SearchIntent data object DismissEmptyScopeAction : SearchIntent data object OpenSourceManage : SearchIntent 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 5e099fc68..242b62b56 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 @@ -20,8 +20,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop @@ -54,6 +56,7 @@ import io.legado.app.R import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword import io.legado.app.domain.model.BookShelfState +import io.legado.app.domain.model.MatchMode import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.theme.LegadoTheme @@ -63,12 +66,12 @@ import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.widget.components.AppFloatingActionButton import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.book.SearchBookListItem import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet -import io.legado.app.ui.widget.components.button.SmallIconButton -import io.legado.app.ui.widget.components.button.SmallTextButton +import io.legado.app.ui.widget.components.button.SmallPlainButton import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.icon.AppIcon @@ -76,11 +79,11 @@ import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.list.TopFloatingStickyItem import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator +import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior -import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.toastOnUi @@ -100,6 +103,8 @@ fun SearchScreen( ) { val context = LocalContext.current val state by viewModel.uiState.collectAsStateWithLifecycle() + val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle() + val isSourceGroupedMode = searchLayoutMode == 1 var previewBook by remember { mutableStateOf(null) } var previewSharedCoverKey by remember { mutableStateOf(null) } val listState = rememberLazyListState() @@ -149,13 +154,15 @@ fun SearchScreen( state.hasMore, state.isManualStop, state.showSuggestions, + isSourceGroupedMode, ) { if ( shouldLoadMore && !state.isSearching && state.hasMore && !state.isManualStop && - !state.showSuggestions + !state.showSuggestions && + !isSourceGroupedMode ) { viewModel.onIntent(SearchIntent.LoadMore) } @@ -261,32 +268,15 @@ 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 -> - viewModel.onIntent(SearchIntent.TogglePrecision(checked)) - }, - iconChecked = AppIcons.PrecisionSearch, - iconUnchecked = AppIcons.UnPrecisionSearch, - activeText = stringResource(R.string.precision_search), - inactiveText = stringResource(R.string.search), - ) - TopBarAnimatedActionButton( - checked = state.selectedSourceTypes.isNotEmpty(), + checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(), onCheckedChange = { - viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true)) + viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true)) }, - iconChecked = Icons.Default.Layers, - iconUnchecked = Icons.Default.Layers, - activeText = "搜素类型", - inactiveText = "搜素类型", + iconChecked = AppIcons.Settings, + iconUnchecked = AppIcons.Settings, + activeText = stringResource(R.string.setting), + inactiveText = stringResource(R.string.setting), ) TopBarAnimatedActionButton( checked = !state.isAllScope, @@ -296,7 +286,7 @@ fun SearchScreen( iconChecked = AppIcons.Filter, iconUnchecked = AppIcons.Filter, activeText = stringResource(R.string.screen), - inactiveText = stringResource(R.string.screen), + inactiveText = stringResource(R.string.screen) ) }, scrollBehavior = scrollBehavior @@ -315,12 +305,13 @@ fun SearchScreen( placeholder = searchLabel, trailingIcon = { if (queryInput.isNotEmpty()) { - TopBarActionButton( + SmallPlainButton( + modifier = Modifier.padding(horizontal = 8.dp), onClick = { queryInput = "" viewModel.onIntent(SearchIntent.UpdateQuery("")) }, - imageVector = AppIcons.Close, + icon = AppIcons.Close, contentDescription = stringResource(R.string.clear) ) } @@ -394,52 +385,124 @@ fun SearchScreen( } if (state.results.isNotEmpty()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - contentPadding = adaptiveContentPaddingOnlyVertical( - top = 48.dp, - bottom = 8.dp - ), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - itemsIndexed( - items = state.results, - key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } - ) { index, item -> - val sharedCoverKey = bookCoverSharedElementKey( - item.book.bookUrl, - "search:${item.book.origin}:$index" - ) - SearchBookListItem( - book = item.book, - shelfState = item.shelfState, - onClick = { - viewModel.onIntent( - SearchIntent.OpenSearchBook( - item.book, - sharedCoverKey - ) - ) - }, - onLongClick = { book, coverKey -> - previewBook = book - previewSharedCoverKey = coverKey - }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) - } + val sourceGroupedResults = remember(state.results) { + state.results + .groupBy { it.book.origin } + .map { (origin, books) -> + SourceGroup( + origin = origin, + sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() } + ?: origin, + items = books + ) + } + } - item { - SearchResultFooter( - isSearching = state.isSearching, - hasMore = state.hasMore, - hasResult = true, - committedQuery = state.committedQuery, - onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, - ) + AnimatedContent( + targetState = isSourceGroupedMode, + label = "SearchLayoutTransition", + modifier = Modifier.fillMaxSize(), + ) { isSourceGrouped -> + if (isSourceGrouped) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPaddingOnlyVertical( + top = 48.dp, + bottom = 8.dp + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + sourceGroupedResults.forEachIndexed { groupIndex, group -> + item(key = "header_${group.origin}") { + SearchSourceSection( + sourceName = group.sourceName, + items = group.items, + onClickBook = { book, coverKey -> + viewModel.onIntent( + SearchIntent.OpenSearchBook( + book, + coverKey + ) + ) + }, + onLongClickBook = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, + onViewAll = { + viewModel.onIntent( + SearchIntent.ExpandSource( + group.origin, + group.sourceName + ) + ) + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sourceSectionIndex = groupIndex, + ) + } + } + + item { + SearchResultFooter( + isSearching = state.isSearching, + hasMore = state.hasMore, + hasResult = true, + committedQuery = state.committedQuery, + onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = adaptiveContentPaddingOnlyVertical( + top = 48.dp, + bottom = 8.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = state.results, + key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } + ) { index, item -> + val sharedCoverKey = bookCoverSharedElementKey( + item.book.bookUrl, + "search:${item.book.origin}:$index" + ) + SearchBookListItem( + book = item.book, + shelfState = item.shelfState, + onClick = { + viewModel.onIntent( + SearchIntent.OpenSearchBook( + item.book, + sharedCoverKey + ) + ) + }, + onLongClick = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey + ) + } + + item { + SearchResultFooter( + isSearching = state.isSearching, + hasMore = state.hasMore, + hasResult = true, + committedQuery = state.committedQuery, + onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, + ) + } + } } } } @@ -502,7 +565,7 @@ fun SearchScreen( }, title = stringResource(R.string.draw), textProvider = { - if (wasPrecisionSearch) { + if (wasMatchMode == MatchMode.EXACT) { stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay) } else { stringResource(R.string.search_empty_scope_switch_all, scopeDisplay) @@ -525,46 +588,101 @@ fun SearchScreen( sources = state.enabledSources, selectedSources = state.selectedScopeSourceUrls, onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) }, - isSourceScope = state.isSourceScope + isSourceScope = state.isSourceScope, + onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) }, ) AppModalBottomSheet( - show = state.showTypeSheet, - onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) }, - title = "搜素类型", + show = state.showSettingsSheet, + onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) }, + title = stringResource(R.string.setting), ) { - Column { - SelectionItemCard( - title = stringResource(R.string.all), - isSelected = state.selectedSourceTypes.isEmpty(), - containerColor = LegadoTheme.colorScheme.onSheetContent, - inSelectionMode = true, - onToggleSelection = { - if (state.selectedSourceTypes.isNotEmpty()) { - state.selectedSourceTypes.forEach { - viewModel.onIntent(SearchIntent.ToggleSourceType(it)) - } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CompactDropdownSettingItem( + title = stringResource(R.string.layout_mode), + selectedValue = searchLayoutMode.toString(), + displayEntries = arrayOf( + stringResource(R.string.search_layout_source_grouped), + stringResource(R.string.search_layout_list) + ), + entryValues = arrayOf("1", "0"), + imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted, + onValueChange = { newValue -> + if (newValue.toInt() != searchLayoutMode) { + viewModel.toggleSearchLayout() } } ) - Spacer(modifier = Modifier.height(8.dp)) + CompactDropdownSettingItem( + title = stringResource(R.string.precision_search), + selectedValue = state.matchMode.value.toString(), + displayEntries = arrayOf( + stringResource(R.string.precision_search), + stringResource(R.string.search) + ), + entryValues = arrayOf( + MatchMode.EXACT.value.toString(), + MatchMode.DEFAULT.value.toString() + ), + imageVector = if (state.matchMode == MatchMode.EXACT) AppIcons.PrecisionSearch else AppIcons.UnPrecisionSearch, + onValueChange = { newValue -> + val mode = MatchMode.of(newValue.toInt()) + if (mode != state.matchMode) { + viewModel.onIntent(SearchIntent.SetMatchMode(mode)) + } + } + ) - listOf( - 0 to stringResource(R.string.noval), - 2 to stringResource(R.string.manga), - 1 to stringResource(R.string.audio), - ).forEach { (type, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon(Icons.Default.Layers, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + AppText( + text = "搜索类型", + style = LegadoTheme.typography.titleSmall + ) + } + + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { SelectionItemCard( - title = label, - isSelected = state.selectedSourceTypes.contains(type), + title = stringResource(R.string.all), + isSelected = state.selectedSourceTypes.isEmpty(), containerColor = LegadoTheme.colorScheme.onSheetContent, inSelectionMode = true, onToggleSelection = { - viewModel.onIntent(SearchIntent.ToggleSourceType(type)) + if (state.selectedSourceTypes.isNotEmpty()) { + state.selectedSourceTypes.forEach { + viewModel.onIntent(SearchIntent.ToggleSourceType(it)) + } + } } ) - Spacer(modifier = Modifier.height(4.dp)) + + listOf( + 0 to stringResource(R.string.noval), + 2 to stringResource(R.string.manga), + 1 to stringResource(R.string.audio), + ).forEach { (type, label) -> + SelectionItemCard( + title = label, + isSelected = state.selectedSourceTypes.contains(type), + containerColor = LegadoTheme.colorScheme.onSheetContent, + inSelectionMode = true, + onToggleSelection = { + viewModel.onIntent(SearchIntent.ToggleSourceType(type)) + } + ) + } } Spacer(modifier = Modifier.height(20.dp)) @@ -588,6 +706,24 @@ fun SearchScreen( viewModel.onAddToShelf(book) }, ) + + ExpandedSourceSheet( + show = state.expandedSourceUrl != null, + sourceName = state.expandedSourceName ?: "", + books = state.expandedSourceBooks, + isLoading = state.expandedSourceLoading, + isEnd = state.expandedSourceEnd, + errorMsg = state.expandedSourceError, + onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) }, + onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) }, + onBookClick = { book, coverKey -> + viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey)) + }, + onBookLongClick = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, + ) } private data class SearchFloatingSummary( @@ -653,10 +789,10 @@ private fun SearchSuggestionPanel( } if (state.history.isNotEmpty()) { - SmallTextButton( + SmallPlainButton( onClick = onClearHistory, text = stringResource(R.string.clear_all), - imageVector = Icons.Default.Close + icon = Icons.Default.Close ) } } @@ -685,9 +821,9 @@ private fun SearchSuggestionPanel( title = history.word, onToggleSelection = { onUseHistory(history.word) }, trailingAction = { - SmallIconButton( + SmallPlainButton( onClick = { onDeleteHistory(history) }, - imageVector = Icons.Default.Close, + icon = Icons.Default.Close, contentDescription = stringResource(R.string.delete) ) } @@ -747,3 +883,76 @@ private fun SearchResultFooter( } } } + +private data class SourceGroup( + val origin: String, + val sourceName: String, + val items: List, +) + +@Composable +private fun ExpandedSourceSheet( + show: Boolean, + sourceName: String, + books: List, + isLoading: Boolean, + isEnd: Boolean, + errorMsg: String?, + onDismiss: () -> Unit, + onLoadMore: () -> Unit, + onBookClick: (SearchBook, String?) -> Unit, + onBookLongClick: ((SearchBook, String?) -> Unit)? = null, +) { + AppModalBottomSheet( + show = show, + onDismissRequest = onDismiss, + title = sourceName, + ) { + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + val total = listState.layoutInfo.totalItemsCount + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + total > 0 && last >= total - 3 + } + } + + LaunchedEffect(shouldLoadMore, isLoading, isEnd) { + if (shouldLoadMore && !isLoading && !isEnd) { + onLoadMore() + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPaddingOnlyVertical( + top = 8.dp, + bottom = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = books, + key = { it.bookUrl }, + ) { book -> + SearchBookListItem( + book = book, + shelfState = BookShelfState.NOT_IN_SHELF, + onClick = { onBookClick(book, null) }, + onLongClick = onBookLongClick, + ) + } + + item { + LoadMoreFooter( + isLoading = isLoading, + errorMsg = errorMsg, + isEnd = isEnd, + onRetry = onLoadMore, + ) + } + } + } +} 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 2889a34e7..5d5fb4dd7 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 @@ -2,50 +2,73 @@ package io.legado.app.ui.book.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.legado.app.constant.PreferKey import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook +import io.legado.app.data.local.preferences.LocalPreferencesKeys +import io.legado.app.data.local.preferences.LocalPreferencesRepository import io.legado.app.data.repository.SearchRepository import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.model.MatchMode import io.legado.app.domain.usecase.AddToBookshelfUseCase import io.legado.app.domain.usecase.BookSearchControl import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.BookShelfKey +import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.putPrefBoolean +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import splitties.init.appCtx @OptIn(ExperimentalCoroutinesApi::class) class SearchViewModel( private val repository: SearchRepository, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val searchBooksUseCase: SearchBooksUseCase, + private val exploreBooksUseCase: ExploreBooksUseCase, private val addToBookshelfUseCase: AddToBookshelfUseCase, + private val localPreferencesRepository: LocalPreferencesRepository, ) : ViewModel() { + val searchLayoutMode = localPreferencesRepository + .getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + fun toggleSearchLayout() { + viewModelScope.launch { + val newMode = if (searchLayoutMode.value == 0) 1 else 0 + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.SEARCH_LAYOUT_MODE, newMode + ) + } + } + + private val matchModeFlow = localPreferencesRepository + .getPreference(LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value) + .distinctUntilChanged() + .map { MatchMode.of(it) } + private val _uiState = MutableStateFlow( SearchUiState( - isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch), scopeDisplay = SearchScope(AppConfig.searchScope).display, - scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames, + scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(), isAllScope = SearchScope(AppConfig.searchScope).isAll(), isSourceScope = SearchScope(AppConfig.searchScope).isSource(), ) @@ -72,6 +95,7 @@ class SearchViewModel( observeBookshelf() observeQueryHistory() observeQueryBookshelfHints() + observeMatchMode() } fun onAddToShelf(book: SearchBook) { @@ -153,8 +177,8 @@ class SearchViewModel( _uiState.update { it.copy(showScopeSheet = intent.visible) } } - is SearchIntent.SetTypeSheetVisible -> { - _uiState.update { it.copy(showTypeSheet = intent.visible) } + is SearchIntent.SetSettingsSheetVisible -> { + _uiState.update { it.copy(showSettingsSheet = intent.visible) } } is SearchIntent.ToggleSourceType -> { @@ -184,9 +208,12 @@ class SearchViewModel( syncScopeState(restartSearch = true, oldScope = oldScope) } - is SearchIntent.TogglePrecision -> { - appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled) - _uiState.update { it.copy(isPrecisionSearch = intent.enabled) } + is SearchIntent.SetMatchMode -> { + viewModelScope.launch { + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.MATCH_MODE, intent.mode.value + ) + } restartCommittedSearchIfNeeded() } @@ -197,6 +224,61 @@ class SearchViewModel( SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage) + is SearchIntent.ExpandSource -> { + _uiState.update { + it.copy( + expandedSourceUrl = intent.sourceUrl, + expandedSourceName = intent.sourceName, + expandedSourceBooks = persistentListOf(), + expandedSourceLoading = true, + expandedSourceEnd = false, + expandedSourceError = null, + expandedSourcePage = 1, + ) + } + loadExpandedSourcePage(intent.sourceUrl, page = 1) + } + + SearchIntent.DismissExpandedSource -> { + _uiState.update { + it.copy( + expandedSourceUrl = null, + expandedSourceName = null, + expandedSourceBooks = persistentListOf(), + expandedSourceLoading = false, + expandedSourceEnd = false, + expandedSourceError = null, + expandedSourcePage = 1, + ) + } + } + + SearchIntent.LoadMoreExpandedSource -> { + val state = _uiState.value + val sourceUrl = state.expandedSourceUrl ?: return + if (state.expandedSourceLoading || state.expandedSourceEnd) return + _uiState.update { + it.copy( + expandedSourceLoading = true, + expandedSourceError = null + ) + } + loadExpandedSourcePage(sourceUrl, page = state.expandedSourcePage) + } + + is SearchIntent.OpenExpandedSourceBook -> { + emitEffect( + SearchEffect.OpenBookInfo( + name = intent.book.name, + author = intent.book.author, + bookUrl = intent.book.bookUrl, + origin = intent.book.origin, + coverPath = intent.book.coverUrl, + sharedCoverKey = intent.sharedCoverKey, + ) + ) + } + is SearchIntent.SaveScrollState -> { _uiState.update { it.copy( @@ -233,7 +315,7 @@ class SearchViewModel( repository.enabledGroups .catch { emit(emptyList()) } .collect { groups -> - _uiState.update { it.copy(enabledGroups = groups) } + _uiState.update { it.copy(enabledGroups = groups.toImmutableList()) } } } } @@ -243,7 +325,7 @@ class SearchViewModel( repository.enabledSources .catch { emit(emptyList()) } .collect { sources -> - _uiState.update { it.copy(enabledSources = sources) } + _uiState.update { it.copy(enabledSources = sources.toImmutableList()) } } } } @@ -255,7 +337,7 @@ class SearchViewModel( .collect { keys -> bookshelfKeys.value = keys _uiState.update { state -> - state.copy(results = state.results.withShelfState(keys)) + state.copy(results = state.results.withShelfState(keys).toImmutableList()) } } } @@ -269,7 +351,7 @@ class SearchViewModel( .flatMapLatest { repository.searchHistory(it) } .catch { emit(emptyList()) } .collect { history -> - _uiState.update { it.copy(history = history) } + _uiState.update { it.copy(history = history.toImmutableList()) } } } } @@ -282,11 +364,19 @@ class SearchViewModel( .flatMapLatest { repository.searchBookshelf(it) } .catch { emit(emptyList()) } .collect { books -> - _uiState.update { it.copy(bookshelfHints = books) } + _uiState.update { it.copy(bookshelfHints = books.toImmutableList()) } } } } + private fun observeMatchMode() { + viewModelScope.launch { + matchModeFlow.collect { mode -> + _uiState.update { it.copy(matchMode = mode) } + } + } + } + private fun updateQuery(query: String, showSuggestions: Boolean) { val currentState = _uiState.value val isSameQuery = currentState.query == query @@ -319,7 +409,7 @@ class SearchViewModel( _uiState.update { it.copy( committedQuery = keyword, - results = emptyList(), + results = persistentListOf(), isManualStop = false, hasMore = true, processedSources = 0, @@ -362,7 +452,7 @@ class SearchViewModel( keyword = keyword, page = page, scope = BookSearchScope(searchScope.toString()), - precision = _uiState.value.isPrecisionSearch, + matchMode = _uiState.value.matchMode, concurrency = OtherConfig.threadCount, types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() }, ), @@ -395,7 +485,7 @@ class SearchViewModel( it.copy( results = buildSearchResultItems( shelf = bookshelfKeys.value, - ), + ).toImmutableList(), processedSources = event.processedSources, totalSources = event.totalSources, ) @@ -407,7 +497,7 @@ class SearchViewModel( val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) { SearchEmptyScopeAction( scopeDisplay = searchScope.display, - wasPrecisionSearch = state.isPrecisionSearch, + wasMatchMode = state.matchMode, ) } else { null @@ -441,7 +531,7 @@ class SearchViewModel( it.copy( query = "", committedQuery = "", - results = emptyList(), + results = persistentListOf(), processedSources = 0, totalSources = 0, isSearching = false, @@ -497,9 +587,12 @@ class SearchViewModel( val action = _uiState.value.emptyScopeAction ?: return _uiState.update { it.copy(emptyScopeAction = null) } - if (action.wasPrecisionSearch) { - appCtx.putPrefBoolean(PreferKey.precisionSearch, false) - _uiState.update { it.copy(isPrecisionSearch = false) } + if (action.wasMatchMode == MatchMode.EXACT) { + viewModelScope.launch { + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value + ) + } } else { searchScope.update("") syncScopeState() @@ -523,7 +616,7 @@ class SearchViewModel( _uiState.update { it.copy( scopeDisplay = searchScope.display, - scopeDisplayNames = searchScope.displayNames, + scopeDisplayNames = searchScope.displayNames.toImmutableList(), selectedScopeSourceUrls = searchScope.sourceUrls.toSet(), isAllScope = searchScope.isAll(), isSourceScope = searchScope.isSource(), @@ -571,6 +664,37 @@ class SearchViewModel( } } + private fun loadExpandedSourcePage(sourceUrl: String, page: Int) { + viewModelScope.launch { + val keyword = _uiState.value.committedQuery + try { + val result = exploreBooksUseCase.execute( + sourceUrl = sourceUrl, + moduleUrl = null, + args = null, + page = page, + key = keyword, + ) + val newBooks = result.books + _uiState.update { + it.copy( + expandedSourceBooks = (it.expandedSourceBooks + newBooks).toImmutableList(), + expandedSourceLoading = false, + expandedSourceEnd = newBooks.isEmpty(), + expandedSourcePage = page + 1, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + expandedSourceLoading = false, + expandedSourceError = e.message ?: "Unknown error", + ) + } + } + } + } + private fun emitEffect(effect: SearchEffect) { _effects.tryEmit(effect) } diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt index ee0cc3af4..f664e4f04 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt @@ -34,11 +34,9 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import io.legado.app.ui.widget.components.AppFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem -import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.animateFloatingActionButton import androidx.compose.runtime.Composable @@ -57,20 +55,23 @@ import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchContentHistory import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.AppFloatingActionButton import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.button.MediumOutlinedButton -import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton -import io.legado.app.ui.widget.components.button.SmallIconButton -import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton -import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.ui.widget.components.button.SmallPlainButton +import io.legado.app.ui.widget.components.button.SmallToggleButton +import io.legado.app.ui.widget.components.button.ToggleStyle import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn +import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -302,14 +303,14 @@ fun SearchHistoryList( color = LegadoTheme.colorScheme.primary, modifier = Modifier.align(Alignment.Center) ) - SmallAnimatedActionButton( + SmallToggleButton( modifier = Modifier.align(Alignment.CenterEnd), checked = onlyThisBook, onCheckedChange = { onToggleScope() }, + style = ToggleStyle.Tonal, iconChecked = Icons.Default.Book, - iconUnchecked = Icons.Default.CollectionsBookmark, - activeText = "仅本书", - inactiveText = "所有记录" + icon = Icons.Default.CollectionsBookmark, + text = "仅本书" ) } @@ -338,9 +339,9 @@ fun SearchHistoryList( Icon(Icons.Default.History, contentDescription = null) }, trailingContent = { - SmallIconButton( + SmallPlainButton( onClick = { onDeleteHistory(item) }, - imageVector = Icons.Default.Close, + icon = Icons.Default.Close, contentDescription = "删除" ) }, @@ -361,7 +362,7 @@ fun SearchHistoryList( MediumOutlinedButton( onClick = onClearHistory, modifier = Modifier.fillMaxWidth(0.6f), - imageVector = Icons.Outlined.DeleteSweep, + icon = Icons.Outlined.DeleteSweep, text = "清除搜索历史" ) }