Browse Source

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

HapeLee 4 months ago
parent
commit
21f9409bd6

+ 17 - 6
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<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
+        }
     }
 
 }

+ 80 - 6
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<String>,
+    val sources: List<BookSourcePart>,
+    val isSourceScope: Boolean,
+)

+ 5 - 0
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<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

+ 1 - 1
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(",")
         }
 
     /**

+ 41 - 0
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<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(

+ 22 - 3
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,