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

This commit is contained in:
HapeLee committed 2026-06-14 13:20:44 +08:00
1 parent 242fba70e8
commit 21f9409bd6
6 files changed
+163 -13

No files matched your search

@@ -1,8 +1,8 @@
package io.legado.app.domain.model package io.legado.app.domain.model
import com.google.gson.GsonBuilder
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.splitNotBlank import io.legado.app.utils.splitNotBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
data class BookSearchScope(val raw: String) { data class BookSearchScope(val raw: String) {
@@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) {
get() = groups.isEmpty() && sources.isEmpty() get() = groups.isEmpty() && sources.isEmpty()
} }
@Serializable
data class ScopeSourceItem( data class ScopeSourceItem(
val name: String, val name: String,
val url: String, val url: String,
) )
@Serializable
private data class SerializedSearchScope( private data class SerializedSearchScope(
val type: String = "", val type: String = "",
val groups: List<String> = emptyList(), val groups: List<String> = emptyList(),
@@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) {
return if (selected.isEmpty()) { return if (selected.isEmpty()) {
"" ""
} else { } 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()) { return if (selected.isEmpty()) {
"" ""
} else { } 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() val json = raw.trim()
if (!json.startsWith("{") || !json.endsWith("}")) return null 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) { when (scope.type) {
TYPE_SOURCE -> ParsedSearchScope( TYPE_SOURCE -> ParsedSearchScope(
sources = scope.sources.filter { it.url.isNotBlank() } 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_GROUP = "group"
private const val TYPE_SOURCE = "source" 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.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar 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.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard 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.icon.AppIcons
@@ -46,9 +47,20 @@ fun ScopeSelectSheet(
isSourceScope: Boolean = false, isSourceScope: Boolean = false,
title: String = stringResource(R.string.search_select_group), title: String = stringResource(R.string.search_select_group),
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
onApplyScope: ((ScopeSelection) -> Unit)? = null,
) { ) {
var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) } var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
var filterText by rememberSaveable(show) { mutableStateOf("") } 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) { val filteredGroups = remember(groups, filterText) {
if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) } if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
@@ -87,12 +99,19 @@ fun ScopeSelectSheet(
SelectionItemCard( SelectionItemCard(
title = stringResource(R.string.all_source), title = stringResource(R.string.all_source),
isSelected = isAll, isSelected = currentIsAll,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { onToggleSelection = {
if (useDraftSelection) {
draftIsAll = true
draftIsSourceScope = false
draftGroups = emptySet()
draftSourceUrls = emptySet()
} else {
onSelectAll() onSelectAll()
} }
}
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -113,15 +132,28 @@ fun ScopeSelectSheet(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(filteredGroups, key = { it }) { groupName -> items(filteredGroups, key = { it }) { groupName ->
val selected = !isSourceScope && selectedGroups.contains(groupName) val selected = !currentIsSourceScope && currentGroups.contains(groupName)
SelectionItemCard( SelectionItemCard(
title = groupName, title = groupName,
isSelected = selected, isSelected = selected,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { onToggleSelection = {
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) onToggleGroup(groupName)
} }
}
) )
} }
} }
@@ -142,7 +174,7 @@ fun ScopeSelectSheet(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(filteredSources, key = { it.bookSourceUrl }) { source -> items(filteredSources, key = { it.bookSourceUrl }) { source ->
val selected = selectedSources.contains(source.bookSourceUrl) val selected = currentSourceUrls.contains(source.bookSourceUrl)
SelectionItemCard( SelectionItemCard(
title = source.bookSourceName, title = source.bookSourceName,
subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() }, subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
@@ -150,15 +182,57 @@ fun ScopeSelectSheet(
isSelected = selected, isSelected = selected,
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { onToggleSelection = {
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) onToggleSource(source)
} }
}
) )
} }
} }
} }
} }
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)) 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 class ToggleSourceType(val type: Int) : SearchIntent
data object ClearAllSourceTypes : SearchIntent data object ClearAllSourceTypes : SearchIntent
data object SelectAllScope : 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 ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
data class RemoveScopeItem(val scopeName: String) : SearchIntent data class RemoveScopeItem(val scopeName: String) : SearchIntent
@@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) {
if (scope.isEmpty()) { if (scope.isEmpty()) {
return appCtx.getString(R.string.all_source) return appCtx.getString(R.string.all_source)
} }
return scope return parsedScope().groupNames.joinToString(",")
} }
/** /**
@@ -112,6 +112,7 @@ fun SearchScreen(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) } var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) }
val showSuggestionPanel = state.showSuggestions val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query) val latestQuery by rememberUpdatedState(state.query)
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { 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 submitSearch: (String) -> Unit = { rawQuery ->
val normalized = rawQuery.trim() val normalized = rawQuery.trim()
if (normalized.isNotBlank()) { if (normalized.isNotBlank()) {
@@ -611,6 +643,15 @@ fun SearchScreen(
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) }, onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope, isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) }, onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
onApplyScope = { selection ->
viewModel.onIntent(
SearchIntent.ApplyScopeSelection(
groupNames = selection.groupNames,
sources = selection.sources,
isSourceScope = selection.isSourceScope,
)
)
},
) )
AppModalBottomSheet( AppModalBottomSheet(
@@ -211,6 +211,7 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent)
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName) is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source) is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
is SearchIntent.RemoveScopeItem -> { is SearchIntent.RemoveScopeItem -> {
@@ -221,6 +222,7 @@ class SearchViewModel(
} }
is SearchIntent.SetMatchMode -> { is SearchIntent.SetMatchMode -> {
_uiState.update { it.copy(matchMode = intent.mode) }
viewModelScope.launch { viewModelScope.launch {
localPreferencesRepository.updatePreference( localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value LocalPreferencesKeys.MATCH_MODE, intent.mode.value
@@ -427,7 +429,6 @@ class SearchViewModel(
it.copy( it.copy(
query = query, query = query,
showSuggestions = showSuggestions, showSuggestions = showSuggestions,
isManualStop = false,
emptyScopeAction = null, emptyScopeAction = null,
) )
} }
@@ -657,6 +658,7 @@ class SearchViewModel(
_uiState.update { it.copy(emptyScopeAction = null) } _uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasMatchMode == MatchMode.EXACT) { if (action.wasMatchMode == MatchMode.EXACT) {
_uiState.update { it.copy(matchMode = MatchMode.DEFAULT) }
viewModelScope.launch { viewModelScope.launch {
localPreferencesRepository.updatePreference( localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
@@ -672,12 +674,29 @@ class SearchViewModel(
} }
private fun restartCommittedSearchIfNeeded() { private fun restartCommittedSearchIfNeeded() {
val committed = _uiState.value.committedQuery val state = _uiState.value
if (committed.isNotBlank()) { val committed = state.committedQuery
if (
committed.isNotBlank() &&
state.query.trim() == committed &&
!state.showSuggestions &&
!state.isManualStop
) {
submitSearch(committed) 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( private fun syncScopeState(
restartSearch: Boolean = false, restartSearch: Boolean = false,
oldScope: String? = null, oldScope: String? = null,