优化搜素逻辑
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user