From 21f9409bd6e0fb5a6c292c575f4f0e664ea86a07 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:02:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E9=80=89=E6=8B=A9=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/domain/model/BookSearchScope.kt | 23 +++-- .../app/ui/book/search/ScopeSelectSheet.kt | 86 +++++++++++++++++-- .../app/ui/book/search/SearchContract.kt | 5 ++ .../legado/app/ui/book/search/SearchScope.kt | 2 +- .../legado/app/ui/book/search/SearchScreen.kt | 41 +++++++++ .../app/ui/book/search/SearchViewModel.kt | 25 +++++- 6 files changed, 166 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt index 6af0a41cb..8e4667357 100644 --- a/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt +++ b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt @@ -1,8 +1,8 @@ package io.legado.app.domain.model -import com.google.gson.GsonBuilder -import io.legado.app.utils.fromJsonObject import io.legado.app.utils.splitNotBlank +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json data class BookSearchScope(val raw: String) { @@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) { get() = groups.isEmpty() && sources.isEmpty() } + @Serializable data class ScopeSourceItem( val name: String, val url: String, ) + @Serializable private data class SerializedSearchScope( val type: String = "", val groups: List = emptyList(), @@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) { return if (selected.isEmpty()) { "" } else { - scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected)) + scopeJson.encodeToString( + SerializedSearchScope(type = TYPE_GROUP, groups = selected) + ) } } @@ -66,7 +70,9 @@ data class BookSearchScope(val raw: String) { return if (selected.isEmpty()) { "" } else { - scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected)) + scopeJson.encodeToString( + SerializedSearchScope(type = TYPE_SOURCE, sources = selected) + ) } } @@ -84,7 +90,9 @@ data class BookSearchScope(val raw: String) { val json = raw.trim() if (!json.startsWith("{") || !json.endsWith("}")) return null - return scopeGson.fromJsonObject(json).getOrNull()?.let { scope -> + return runCatching { + scopeJson.decodeFromString(json) + }.getOrNull()?.let { scope -> when (scope.type) { TYPE_SOURCE -> ParsedSearchScope( sources = scope.sources.filter { it.url.isNotBlank() } @@ -124,7 +132,10 @@ data class BookSearchScope(val raw: String) { private const val TYPE_GROUP = "group" private const val TYPE_SOURCE = "source" - private val scopeGson = GsonBuilder().disableHtmlEscaping().create() + private val scopeJson = Json { + encodeDefaults = false + ignoreUnknownKeys = true + } } } 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 8da45444b..37fd8f475 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 @@ -25,6 +25,7 @@ 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.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.button.series.MediumPlainButton import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.icon.AppIcons @@ -46,9 +47,20 @@ fun ScopeSelectSheet( isSourceScope: Boolean = false, title: String = stringResource(R.string.search_select_group), onConfirm: (() -> Unit)? = null, + onApplyScope: ((ScopeSelection) -> Unit)? = null, ) { var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) } var filterText by rememberSaveable(show) { mutableStateOf("") } + var draftIsAll by remember(show, isAll) { mutableStateOf(isAll) } + var draftIsSourceScope by remember(show, isSourceScope) { mutableStateOf(isSourceScope) } + var draftGroups by remember(show, selectedGroups) { mutableStateOf(selectedGroups.toSet()) } + var draftSourceUrls by remember(show, selectedSources) { mutableStateOf(selectedSources.toSet()) } + + val useDraftSelection = onApplyScope != null + val currentIsAll = if (useDraftSelection) draftIsAll else isAll + val currentIsSourceScope = if (useDraftSelection) draftIsSourceScope else isSourceScope + val currentGroups = if (useDraftSelection) draftGroups else selectedGroups + val currentSourceUrls = if (useDraftSelection) draftSourceUrls else selectedSources val filteredGroups = remember(groups, filterText) { if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) } @@ -87,11 +99,18 @@ fun ScopeSelectSheet( SelectionItemCard( title = stringResource(R.string.all_source), - isSelected = isAll, + isSelected = currentIsAll, containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), inSelectionMode = true, onToggleSelection = { - onSelectAll() + if (useDraftSelection) { + draftIsAll = true + draftIsSourceScope = false + draftGroups = emptySet() + draftSourceUrls = emptySet() + } else { + onSelectAll() + } } ) @@ -113,14 +132,27 @@ fun ScopeSelectSheet( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(filteredGroups, key = { it }) { groupName -> - val selected = !isSourceScope && selectedGroups.contains(groupName) + val selected = !currentIsSourceScope && currentGroups.contains(groupName) SelectionItemCard( title = groupName, isSelected = selected, containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), inSelectionMode = true, onToggleSelection = { - onToggleGroup(groupName) + if (useDraftSelection) { + val next = currentGroups.toMutableSet() + if (!currentIsSourceScope && next.contains(groupName)) { + next.remove(groupName) + } else { + next.add(groupName) + } + draftGroups = next + draftSourceUrls = emptySet() + draftIsSourceScope = false + draftIsAll = next.isEmpty() + } else { + onToggleGroup(groupName) + } } ) } @@ -142,7 +174,7 @@ fun ScopeSelectSheet( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(filteredSources, key = { it.bookSourceUrl }) { source -> - val selected = selectedSources.contains(source.bookSourceUrl) + val selected = currentSourceUrls.contains(source.bookSourceUrl) SelectionItemCard( title = source.bookSourceName, subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() }, @@ -150,7 +182,20 @@ fun ScopeSelectSheet( isSelected = selected, inSelectionMode = true, onToggleSelection = { - onToggleSource(source) + if (useDraftSelection) { + val next = currentSourceUrls.toMutableSet() + if (next.contains(source.bookSourceUrl)) { + next.remove(source.bookSourceUrl) + } else { + next.add(source.bookSourceUrl) + } + draftSourceUrls = next + draftGroups = emptySet() + draftIsSourceScope = next.isNotEmpty() + draftIsAll = next.isEmpty() + } else { + onToggleSource(source) + } } ) } @@ -158,7 +203,36 @@ fun ScopeSelectSheet( } } + if (onApplyScope != null) { + Spacer(modifier = Modifier.height(16.dp)) + ConfirmDismissButtonsRow( + onDismiss = onDismissRequest, + onConfirm = { + onApplyScope( + ScopeSelection( + groupNames = if (!draftIsSourceScope) draftGroups.toList() else emptyList(), + sources = if (draftIsSourceScope) { + sources.filter { draftSourceUrls.contains(it.bookSourceUrl) } + } else { + emptyList() + }, + isSourceScope = draftIsSourceScope, + ) + ) + onDismissRequest() + }, + dismissText = stringResource(R.string.cancel), + confirmText = stringResource(R.string.confirm), + ) + } + Spacer(modifier = Modifier.height(20.dp)) } } } + +data class ScopeSelection( + val groupNames: List, + val sources: List, + val isSourceScope: Boolean, +) 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 cdd7cdbaa..5569fcc4b 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 @@ -88,6 +88,11 @@ sealed interface SearchIntent { data class ToggleSourceType(val type: Int) : SearchIntent data object ClearAllSourceTypes : SearchIntent data object SelectAllScope : SearchIntent + data class ApplyScopeSelection( + val groupNames: List, + val sources: List, + val isSourceScope: Boolean, + ) : SearchIntent data class ToggleScopeGroup(val groupName: String) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class RemoveScopeItem(val scopeName: String) : 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 d9185f2f6..3e60b12ba 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 @@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) { if (scope.isEmpty()) { return appCtx.getString(R.string.all_source) } - return scope + return parsedScope().groupNames.joinToString(",") } /** 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 cf98fe7ad..a65fa2883 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 @@ -112,6 +112,7 @@ fun SearchScreen( val lifecycleOwner = LocalLifecycleOwner.current var queryInput by rememberSaveable { mutableStateOf(state.query) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf(null) } + var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) } val showSuggestionPanel = state.showSuggestions val latestQuery by rememberUpdatedState(state.query) val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { @@ -243,6 +244,37 @@ fun SearchScreen( } } + LaunchedEffect(state.committedQuery) { + keepResultsPinnedToTop = true + } + + LaunchedEffect(isSourceGroupedMode, listState, groupedListState) { + snapshotFlow { + val activeState = if (isSourceGroupedMode) groupedListState else listState + Triple( + activeState.firstVisibleItemIndex, + activeState.firstVisibleItemScrollOffset, + activeState.isScrollInProgress + ) + }.collect { (index, offset, isScrollInProgress) -> + if (index == 0 && offset == 0) { + keepResultsPinnedToTop = true + } else if (isScrollInProgress) { + keepResultsPinnedToTop = false + } + } + } + + val firstResultKey = state.results.firstOrNull()?.let { + "${it.book.origin}:${it.book.bookUrl}" + } + LaunchedEffect(firstResultKey, state.results.size, state.isSearching) { + if (state.isSearching && keepResultsPinnedToTop && state.results.isNotEmpty()) { + listState.scrollToItem(0) + groupedListState.scrollToItem(0) + } + } + val submitSearch: (String) -> Unit = { rawQuery -> val normalized = rawQuery.trim() if (normalized.isNotBlank()) { @@ -611,6 +643,15 @@ fun SearchScreen( onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) }, isSourceScope = state.isSourceScope, onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) }, + onApplyScope = { selection -> + viewModel.onIntent( + SearchIntent.ApplyScopeSelection( + groupNames = selection.groupNames, + sources = selection.sources, + isSourceScope = selection.isSourceScope, + ) + ) + }, ) AppModalBottomSheet( 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 2b3865185..dbb5dd587 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 @@ -211,6 +211,7 @@ class SearchViewModel( syncScopeState(restartSearch = true, oldScope = oldScope) } + is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent) is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName) is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source) is SearchIntent.RemoveScopeItem -> { @@ -221,6 +222,7 @@ class SearchViewModel( } is SearchIntent.SetMatchMode -> { + _uiState.update { it.copy(matchMode = intent.mode) } viewModelScope.launch { localPreferencesRepository.updatePreference( LocalPreferencesKeys.MATCH_MODE, intent.mode.value @@ -427,7 +429,6 @@ class SearchViewModel( it.copy( query = query, showSuggestions = showSuggestions, - isManualStop = false, emptyScopeAction = null, ) } @@ -657,6 +658,7 @@ class SearchViewModel( _uiState.update { it.copy(emptyScopeAction = null) } if (action.wasMatchMode == MatchMode.EXACT) { + _uiState.update { it.copy(matchMode = MatchMode.DEFAULT) } viewModelScope.launch { localPreferencesRepository.updatePreference( LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value @@ -672,12 +674,29 @@ class SearchViewModel( } private fun restartCommittedSearchIfNeeded() { - val committed = _uiState.value.committedQuery - if (committed.isNotBlank()) { + val state = _uiState.value + val committed = state.committedQuery + if ( + committed.isNotBlank() && + state.query.trim() == committed && + !state.showSuggestions && + !state.isManualStop + ) { submitSearch(committed) } } + private fun applyScopeSelection(intent: SearchIntent.ApplyScopeSelection) { + val oldScope = searchScope.toString() + when { + intent.isSourceScope -> searchScope.updateSources(intent.sources) + intent.groupNames.isNotEmpty() -> searchScope.update(intent.groupNames) + else -> searchScope.update("") + } + persistSearchScope() + syncScopeState(restartSearch = true, oldScope = oldScope) + } + private fun syncScopeState( restartSearch: Boolean = false, oldScope: String? = null,