优化搜素逻辑

This commit is contained in:
HapeLee
2026-05-10 23:34:20 +08:00
parent 6a0954377a
commit 6a39b42fe5
8 changed files with 144 additions and 64 deletions
+6 -1
View File
@@ -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)"
]
}
}
@@ -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()
@@ -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 {
@@ -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)
}
)
}
@@ -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()) {
@@ -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>()
@@ -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
View File
@@ -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>