fix: 修复搜索范围选择行为

This commit is contained in:
HapeLee
2026-06-14 13:20:44 +08:00
parent 242fba70e8
commit 21f9409bd6
6 changed files with 166 additions and 16 deletions
@@ -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<String> = 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<SerializedSearchScope>(json).getOrNull()?.let { scope ->
return runCatching {
scopeJson.decodeFromString<SerializedSearchScope>(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
}
}
}
@@ -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<String>,
val sources: List<BookSourcePart>,
val isSourceScope: Boolean,
)
@@ -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<String>,
val sources: List<BookSourcePart>,
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
@@ -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(",")
}
/**
@@ -112,6 +112,7 @@ fun SearchScreen(
val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(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(
@@ -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,