Explorar o código

优化搜素逻辑

HapeLee hai 6 meses
pai
achega
6a39b42fe5

+ 6 - 1
.claude/settings.local.json

@@ -44,7 +44,12 @@
       "Bash(jar tf *)",
       "Read(//d/tmp/**)",
       "Bash(jar xf *)",
-      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)"
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 5)",
+      "PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)",
+      "Bash(npx skills *)",
+      "Bash(npx *)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)"
     ]
   }
 }

+ 3 - 10
app/src/main/java/io/legado/app/base/BaseService.kt

@@ -16,13 +16,13 @@ import io.legado.app.utils.LogUtils
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.isActive
 import kotlinx.coroutines.sync.Semaphore
 import kotlin.coroutines.CoroutineContext
 
 abstract class BaseService : LifecycleService() {
 
     private val simpleName = this::class.simpleName.toString()
+    @Volatile
     private var isForeground = false
 
     fun <T> execute(
@@ -37,6 +37,8 @@ abstract class BaseService : LifecycleService() {
     @CallSuper
     override fun onCreate() {
         super.onCreate()
+        startForegroundNotification()
+        isForeground = true
         LifecycleHelp.onServiceCreate(this)
         if (!AppConfig.permissionChecked) {
             AppConfig.permissionChecked = true
@@ -49,10 +51,6 @@ abstract class BaseService : LifecycleService() {
         LogUtils.d(simpleName) {
             "onStartCommand $intent ${intent?.toUri(0)}"
         }
-        if (!isForeground) {
-            startForegroundNotification()
-            isForeground = true
-        }
         return super.onStartCommand(intent, flags, startId)
     }
 
@@ -95,11 +93,6 @@ abstract class BaseService : LifecycleService() {
         PermissionsCompat.Builder()
             .addPermissions(Permissions.POST_NOTIFICATIONS)
             .rationale(R.string.notification_permission_rationale)
-            .onGranted {
-                if (lifecycleScope.isActive) {
-                    startForegroundNotification()
-                }
-            }
             .request()
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
             PermissionsCompat.Builder()

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -1,16 +1,19 @@
 package io.legado.app.ui.book.search
 
+import androidx.compose.runtime.Stable
 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.ui.main.bookshelf.BookShelfItem
 
+@Stable
 data class SearchResultItemUi(
     val book: SearchBook,
     val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
 )
 
+@Stable
 data class SearchUiState(
     val query: String = "",
     val committedQuery: String = "",
@@ -34,6 +37,8 @@ data class SearchUiState(
     val showClearHistoryDialog: Boolean = false,
     val showSuggestions: Boolean = true,
     val emptyScopeAction: SearchEmptyScopeAction? = null,
+    val savedScrollIndex: Int = 0,
+    val savedScrollOffset: Int = 0,
 )
 
 data class SearchEmptyScopeAction(
@@ -47,6 +52,7 @@ sealed interface SearchIntent {
     data object SubmitSearch : SearchIntent
     data object LoadMore : SearchIntent
     data object StopSearch : SearchIntent
+    data object ClearSearchResults : SearchIntent
     data object PauseEngine : SearchIntent
     data object ResumeEngine : SearchIntent
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
@@ -64,6 +70,7 @@ sealed interface SearchIntent {
     data object ConfirmEmptyScopeAction : SearchIntent
     data object DismissEmptyScopeAction : SearchIntent
     data object OpenSourceManage : SearchIntent
+    data class SaveScrollState(val index: Int, val offset: Int) : SearchIntent
 }
 
 sealed interface SearchEffect {

+ 57 - 23
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -1,10 +1,10 @@
 package io.legado.app.ui.book.search
 
+import androidx.activity.compose.BackHandler
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -17,35 +17,22 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyRow
 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.filled.ArrowBack
 import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.FilterList
 import androidx.compose.material.icons.filled.History
 import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.Stop
-import androidx.compose.material3.AssistChip
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
-import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -63,8 +50,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.SearchKeyword
@@ -84,11 +73,9 @@ 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.card.NormalCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
-import io.legado.app.ui.widget.components.button.ToggleChip
 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.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
@@ -113,6 +100,7 @@ fun SearchScreen(
     val context = LocalContext.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val listState = rememberLazyListState()
+    val lifecycleOwner = LocalLifecycleOwner.current
     var queryInput by rememberSaveable { mutableStateOf(state.query) }
     var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
     var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
@@ -190,9 +178,50 @@ fun SearchScreen(
         }
     }
 
+    // Activity lifecycle (e.g., Home button, switching apps)
+    DisposableEffect(lifecycleOwner, viewModel) {
+        val observer = LifecycleEventObserver { _, event ->
+            when (event) {
+                Lifecycle.Event.ON_RESUME -> viewModel.onIntent(SearchIntent.ResumeEngine)
+                Lifecycle.Event.ON_PAUSE -> viewModel.onIntent(SearchIntent.PauseEngine)
+                else -> Unit
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose {
+            lifecycleOwner.lifecycle.removeObserver(observer)
+        }
+    }
+
+    // Composition lifecycle (e.g., navigating to BookInfo and back)
     DisposableEffect(viewModel) {
         onDispose {
-            viewModel.onIntent(SearchIntent.StopSearch)
+            viewModel.onIntent(SearchIntent.PauseEngine)
+        }
+    }
+
+    LaunchedEffect(viewModel) {
+        viewModel.onIntent(SearchIntent.ResumeEngine)
+    }
+
+    // Save scroll position before composable leaves composition (e.g., navigating to BookInfo)
+    DisposableEffect(viewModel) {
+        onDispose {
+            val first = listState.firstVisibleItemIndex
+            val offset = listState.firstVisibleItemScrollOffset
+            if (first > 0 || offset > 0) {
+                viewModel.onIntent(SearchIntent.SaveScrollState(first, offset))
+            }
+        }
+    }
+
+    // Restore scroll position when composable re-enters with saved state
+    LaunchedEffect(state.savedScrollIndex, state.savedScrollOffset) {
+        val idx = state.savedScrollIndex
+        val off = state.savedScrollOffset
+        if (idx > 0 || off > 0) {
+            listState.scrollToItem(idx, off)
+            viewModel.onIntent(SearchIntent.SaveScrollState(0, 0))
         }
     }
 
@@ -207,6 +236,11 @@ fun SearchScreen(
             viewModel.onIntent(SearchIntent.SubmitSearch)
         }
     }
+
+    BackHandler {
+        onBack()
+    }
+
     val searchLabel = stringResource(R.string.search_book_key)
     val showResultCountCard = state.committedQuery.isNotBlank() || state.isSearching
     val showSourceProgressCard = state.totalSources > 0
@@ -249,7 +283,7 @@ fun SearchScreen(
                                 viewModel.onIntent(SearchIntent.OpenSourceManage)
                             },
                             imageVector = AppIcons.Settings,
-                            contentDescription = "书源管理"
+                            contentDescription = stringResource(R.string.book_source_manage)
                         )
                     },
                     scrollBehavior = scrollBehavior
@@ -390,8 +424,8 @@ fun SearchScreen(
                         TopFloatingStickyItem(
                             item = if (showResultCountCard || showSourceProgressCard) {
                                 SearchFloatingSummary(
-                                    resultText = if (showResultCountCard) "结果 ${state.results.size}" else null,
-                                    sourceText = if (showSourceProgressCard) " · 进度 ${state.processedSources}/${state.totalSources}" else null,
+                                    resultText = if (showResultCountCard) stringResource(R.string.search_result_count, state.results.size) else null,
+                                    sourceText = if (showSourceProgressCard) stringResource(R.string.search_source_progress, state.processedSources, state.totalSources) else null,
                                 )
                             } else {
                                 null
@@ -451,9 +485,9 @@ fun SearchScreen(
         content = {
             Text(
                 text = if (it.wasPrecisionSearch) {
-                    "${it.scopeDisplay}分组搜索结果为空,是否关闭精准搜索?"
+                    stringResource(R.string.search_empty_scope_disable_precision, it.scopeDisplay)
                 } else {
-                    "${it.scopeDisplay}分组搜索结果为空,是否切换到全部分组?"
+                    stringResource(R.string.search_empty_scope_switch_all, it.scopeDisplay)
                 }
             )
         }

+ 47 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -62,6 +62,7 @@ class SearchViewModel(
 
     private var searchJob: Job? = null
     private var currentSearchPage = 1
+    private var wasSearching = false
 
     init {
         syncScopeState()
@@ -79,8 +80,22 @@ class SearchViewModel(
             SearchIntent.SubmitSearch -> submitSearch()
             SearchIntent.LoadMore -> loadMore()
             SearchIntent.StopSearch -> stopSearch()
-            SearchIntent.PauseEngine -> searchControl.pause()
-            SearchIntent.ResumeEngine -> searchControl.resume()
+            SearchIntent.ClearSearchResults -> clearSearchResults()
+            SearchIntent.PauseEngine -> {
+                wasSearching = wasSearching || (searchJob?.isActive == true)
+                searchControl.pause()
+            }
+
+            SearchIntent.ResumeEngine -> {
+                searchControl.resume()
+                if (wasSearching) {
+                    val state = _uiState.value
+                    if (state.committedQuery.isNotBlank() && searchJob?.isActive != true) {
+                        startSearch(state.committedQuery, currentSearchPage)
+                    }
+                    wasSearching = false
+                }
+            }
             is SearchIntent.UseHistoryKeyword -> {
                 updateQuery(intent.keyword, showSuggestions = false)
                 submitSearch(intent.keyword)
@@ -151,6 +166,15 @@ class SearchViewModel(
             }
 
             SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
+
+            is SearchIntent.SaveScrollState -> {
+                _uiState.update {
+                    it.copy(
+                        savedScrollIndex = intent.index,
+                        savedScrollOffset = intent.offset,
+                    )
+                }
+            }
         }
     }
 
@@ -299,6 +323,7 @@ class SearchViewModel(
     private fun startSearch(keyword: String, page: Int) {
         searchJob?.cancel()
         searchControl.resume()
+        wasSearching = true
         searchJob = viewModelScope.launch {
             try {
                 searchBooksUseCase
@@ -369,6 +394,7 @@ class SearchViewModel(
     private fun stopSearch(manualStop: Boolean = true) {
         searchJob?.cancel()
         searchJob = null
+        wasSearching = false
         _uiState.update {
             it.copy(
                 isSearching = false,
@@ -377,6 +403,25 @@ class SearchViewModel(
         }
     }
 
+    private fun clearSearchResults() {
+        stopSearch(manualStop = true)
+        searchResultBooks.clear()
+        _uiState.update {
+            it.copy(
+                query = "",
+                committedQuery = "",
+                results = emptyList(),
+                processedSources = 0,
+                totalSources = 0,
+                isSearching = false,
+                isManualStop = false,
+                hasMore = true,
+                showSuggestions = true,
+                emptyScopeAction = null,
+            )
+        }
+    }
+
     private fun toggleScopeGroup(groupName: String) {
         val oldScope = searchScope.toString()
         if (searchScope.isSource()) {

+ 16 - 28
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -622,7 +622,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                     }
                 ) { route ->
                     val searchViewModel = koinViewModel<SearchViewModel>()
-                    val lifecycleOwner = LocalLifecycleOwner.current
 
                     LaunchedEffect(route.key, route.scopeRaw, searchViewModel) {
                         searchViewModel.onIntent(
@@ -633,29 +632,12 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                         )
                     }
 
-                    DisposableEffect(lifecycleOwner, searchViewModel) {
-                        val observer = LifecycleEventObserver { _, event ->
-                            when (event) {
-                                Lifecycle.Event.ON_RESUME -> {
-                                    searchViewModel.onIntent(SearchIntent.ResumeEngine)
-                                }
-
-                                Lifecycle.Event.ON_PAUSE -> {
-                                    searchViewModel.onIntent(SearchIntent.PauseEngine)
-                                }
-
-                                else -> Unit
-                            }
-                        }
-                        lifecycleOwner.lifecycle.addObserver(observer)
-                        onDispose {
-                            lifecycleOwner.lifecycle.removeObserver(observer)
-                        }
-                    }
-
                     SearchScreen(
                         viewModel = searchViewModel,
-                        onBack = { navigateBack(backStack) },
+                        onBack = {
+                            searchViewModel.onIntent(SearchIntent.ClearSearchResults)
+                            navigateBack(backStack)
+                        },
                         onOpenBookInfo = { name, author, bookUrl ->
                             navigateToRoute(
                                 backStack,
@@ -715,14 +697,20 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
 
                 entry<MainRouteBookInfo>(
                     metadata = NavDisplay.transitionSpec {
-                        fadeIn(animationSpec = tween(300)) togetherWith
-                            fadeOut(animationSpec = tween(300))
+                        if (initialState.key is MainRouteExploreShow) {
+                            fadeIn(animationSpec = tween(300)) togetherWith
+                                fadeOut(animationSpec = tween(300))
+                        } else null
                     } + NavDisplay.popTransitionSpec {
-                        fadeIn(animationSpec = tween(300)) togetherWith
-                            fadeOut(animationSpec = tween(300))
+                        if (targetState.key is MainRouteExploreShow) {
+                            fadeIn(animationSpec = tween(300)) togetherWith
+                                fadeOut(animationSpec = tween(300))
+                        } else null
                     } + NavDisplay.predictivePopTransitionSpec { _ ->
-                        fadeIn(animationSpec = tween(300)) togetherWith
-                            fadeOut(animationSpec = tween(300))
+                        if (targetState.key is MainRouteExploreShow) {
+                            fadeIn(animationSpec = tween(300)) togetherWith
+                                fadeOut(animationSpec = tween(300))
+                        } else null
                     }
                 ) { route ->
                     val bookInfoViewModel = koinViewModel<BookInfoViewModel>()

+ 4 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1474,4 +1474,8 @@
     <string name="bookshelf_empty_global_search">没有书籍,尝试全局搜索</string>
     <string name="global_search">全局搜索</string>
     <string name="cache_management">缓存管理</string>
+    <string name="search_result_count">结果 %1$d</string>
+    <string name="search_source_progress"> · 进度 %1$d/%2$d</string>
+    <string name="search_empty_scope_disable_precision">%1$s分组搜索结果为空,是否关闭精准搜索?</string>
+    <string name="search_empty_scope_switch_all">%1$s分组搜索结果为空,是否切换到全部分组?</string>
 </resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -1518,4 +1518,8 @@
     <string name="bookshelf_empty_global_search">No books here. Try global search.</string>
     <string name="global_search">Global search</string>
     <string name="cache_management">Cache management</string>
+    <string name="search_result_count">Results %1$d</string>
+    <string name="search_source_progress"> · Progress %1$d/%2$d</string>
+    <string name="search_empty_scope_disable_precision">%1$s group search returned no results. Disable precise search?</string>
+    <string name="search_empty_scope_switch_all">%1$s group search returned no results. Switch to all groups?</string>
 </resources>