HapeLee 6 месяцев назад
Родитель
Сommit
7f8e48fa83

+ 177 - 0
app/src/main/java/io/legado/app/data/repository/SearchRepository.kt

@@ -0,0 +1,177 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.AppDatabase
+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.help.book.isNotShelf
+import io.legado.app.model.webBook.SearchModel
+import io.legado.app.ui.book.search.BookKey
+import io.legado.app.ui.book.search.SearchScope
+import io.legado.app.ui.main.bookshelf.BookShelfItem
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+interface SearchRepository {
+    val enabledGroups: Flow<List<String>>
+    val enabledSources: Flow<List<BookSourcePart>>
+    val bookshelfKeys: Flow<Set<BookKey>>
+
+    fun searchBookshelf(query: String): Flow<List<BookShelfItem>>
+    fun searchHistory(query: String): Flow<List<SearchKeyword>>
+
+    suspend fun saveSearchKeyword(keyword: String)
+    suspend fun deleteSearchKeyword(item: SearchKeyword)
+    suspend fun clearSearchKeywords()
+
+    fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession
+}
+
+sealed interface SearchSessionEvent {
+    data object Started : SearchSessionEvent
+
+    data class Progress(
+        val books: List<SearchBook>,
+        val processedSources: Int,
+        val totalSources: Int,
+    ) : SearchSessionEvent
+
+    data class Finished(
+        val isEmpty: Boolean,
+        val hasMore: Boolean,
+    ) : SearchSessionEvent
+
+    data class Canceled(val throwable: Throwable? = null) : SearchSessionEvent
+}
+
+interface SearchSession {
+    val events: Flow<SearchSessionEvent>
+    fun search(searchId: Long, keyword: String)
+    fun stop()
+    fun pause()
+    fun resume()
+    fun close()
+}
+
+class SearchRepositoryImpl(
+    private val appDb: AppDatabase,
+) : SearchRepository {
+
+    override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
+    override val enabledSources: Flow<List<BookSourcePart>> = appDb.bookSourceDao.flowEnabled()
+
+    override val bookshelfKeys: Flow<Set<BookKey>> = appDb.bookDao.flowBookShelf().map { books ->
+        books.filterNot { it.isNotShelf }
+            .map { book -> BookKey(book.name, book.author, book.bookUrl) }
+            .toSet()
+    }
+
+    override fun searchBookshelf(query: String): Flow<List<BookShelfItem>> {
+        val keyword = query.trim()
+        return if (keyword.isBlank()) {
+            flowOf(emptyList())
+        } else {
+            appDb.bookDao.flowBookShelfSearch(keyword)
+        }
+    }
+
+    override fun searchHistory(query: String): Flow<List<SearchKeyword>> {
+        val keyword = query.trim()
+        return if (keyword.isBlank()) {
+            appDb.searchKeywordDao.flowByTime()
+        } else {
+            appDb.searchKeywordDao.flowSearch(keyword)
+        }
+    }
+
+    override suspend fun saveSearchKeyword(keyword: String) = withContext(Dispatchers.IO) {
+        val key = keyword.trim()
+        if (key.isBlank()) return@withContext
+
+        appDb.searchKeywordDao.get(key)?.let { history ->
+            history.usage += 1
+            history.lastUseTime = System.currentTimeMillis()
+            appDb.searchKeywordDao.update(history)
+        } ?: appDb.searchKeywordDao.insert(SearchKeyword(word = key, usage = 1))
+    }
+
+    override suspend fun deleteSearchKeyword(item: SearchKeyword) = withContext(Dispatchers.IO) {
+        appDb.searchKeywordDao.delete(item)
+    }
+
+    override suspend fun clearSearchKeywords() = withContext(Dispatchers.IO) {
+        appDb.searchKeywordDao.deleteAll()
+    }
+
+    override fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession {
+        return SearchSessionImpl(scopeProvider)
+    }
+
+    private class SearchSessionImpl(
+        scopeProvider: () -> SearchScope,
+    ) : SearchSession {
+
+        private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+        private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 64)
+        override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
+
+        private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
+            override fun getSearchScope(): SearchScope = scopeProvider()
+
+            override fun onSearchStart() {
+                _events.tryEmit(SearchSessionEvent.Started)
+            }
+
+            override fun onSearchSuccess(
+                searchBooks: List<SearchBook>,
+                processedSources: Int,
+                totalSources: Int,
+            ) {
+                _events.tryEmit(
+                    SearchSessionEvent.Progress(
+                        books = searchBooks.toList(),
+                        processedSources = processedSources,
+                        totalSources = totalSources,
+                    )
+                )
+            }
+
+            override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
+                _events.tryEmit(SearchSessionEvent.Finished(isEmpty, hasMore))
+            }
+
+            override fun onSearchCancel(exception: Throwable?) {
+                _events.tryEmit(SearchSessionEvent.Canceled(exception))
+            }
+        })
+
+        override fun search(searchId: Long, keyword: String) {
+            searchModel.search(searchId, keyword)
+        }
+
+        override fun stop() {
+            searchModel.cancelSearch()
+        }
+
+        override fun pause() {
+            searchModel.pause()
+        }
+
+        override fun resume() {
+            searchModel.resume()
+        }
+
+        override fun close() {
+            searchModel.close()
+            sessionScope.cancel()
+        }
+    }
+}

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

@@ -0,0 +1,7 @@
+package io.legado.app.ui.book.search
+
+data class BookKey(
+    val name: String,
+    val author: String,
+    val url: String?
+)

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

@@ -0,0 +1,73 @@
+package io.legado.app.ui.book.search
+
+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.ui.main.bookshelf.BookShelfItem
+
+data class SearchUiState(
+    val query: String = "",
+    val committedQuery: String = "",
+    val results: List<SearchBook> = emptyList(),
+    val history: List<SearchKeyword> = emptyList(),
+    val bookshelfHints: List<BookShelfItem> = emptyList(),
+    val enabledGroups: List<String> = emptyList(),
+    val enabledSources: List<BookSourcePart> = emptyList(),
+    val scopeDisplay: String = "",
+    val scopeDisplayNames: List<String> = emptyList(),
+    val selectedScopeSourceUrls: Set<String> = emptySet(),
+    val isAllScope: Boolean = true,
+    val isSourceScope: Boolean = false,
+    val isPrecisionSearch: Boolean = false,
+    val isSearching: Boolean = false,
+    val isManualStop: Boolean = false,
+    val hasMore: Boolean = true,
+    val processedSources: Int = 0,
+    val totalSources: Int = 0,
+    val showScopeSheet: Boolean = false,
+    val showClearHistoryDialog: Boolean = false,
+    val showSuggestions: Boolean = true,
+    val emptyScopeAction: SearchEmptyScopeAction? = null,
+)
+
+data class SearchEmptyScopeAction(
+    val scopeDisplay: String,
+    val wasPrecisionSearch: Boolean,
+)
+
+sealed interface SearchIntent {
+    data class Initialize(val key: String?, val scopeRaw: String?) : SearchIntent
+    data class UpdateQuery(val query: String) : SearchIntent
+    data object SubmitSearch : SearchIntent
+    data object LoadMore : SearchIntent
+    data object StopSearch : SearchIntent
+    data object PauseEngine : SearchIntent
+    data object ResumeEngine : SearchIntent
+    data class UseHistoryKeyword(val keyword: String) : SearchIntent
+    data class OpenSearchBook(val book: SearchBook) : SearchIntent
+    data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
+    data class DeleteHistory(val item: SearchKeyword) : SearchIntent
+    data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
+    data object ConfirmClearHistory : SearchIntent
+    data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
+    data object SelectAllScope : SearchIntent
+    data class ToggleScopeGroup(val groupName: String) : SearchIntent
+    data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
+    data class RemoveScopeItem(val scopeName: String) : SearchIntent
+    data class TogglePrecision(val enabled: Boolean) : SearchIntent
+    data object ConfirmEmptyScopeAction : SearchIntent
+    data object DismissEmptyScopeAction : SearchIntent
+    data object OpenSourceManage : SearchIntent
+}
+
+sealed interface SearchEffect {
+    data class OpenBookInfo(
+        val name: String,
+        val author: String,
+        val bookUrl: String,
+    ) : SearchEffect
+
+    data object OpenSourceManage : SearchEffect
+
+    data class ShowMessage(val message: String) : SearchEffect
+}

+ 668 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -0,0 +1,668 @@
+package io.legado.app.ui.book.search
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+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.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.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.data.entities.SearchKeyword
+import io.legado.app.ui.main.bookshelf.BookShelfItem
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppSearchBar
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.book.SearchBookListItem
+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.SelectionItemCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.button.ToggleChip
+import io.legado.app.ui.widget.components.button.TopBarActionButton
+import io.legado.app.ui.widget.components.button.TopBarAnimatedActionButton
+import io.legado.app.ui.widget.components.button.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.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+
+@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
+@Composable
+fun SearchScreen(
+    viewModel: SearchViewModel,
+    onBack: () -> Unit,
+    onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
+    onOpenSourceManage: () -> Unit,
+) {
+    val context = LocalContext.current
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val listState = rememberLazyListState()
+    var queryInput by rememberSaveable { mutableStateOf(state.query) }
+    var searchExpanded by rememberSaveable { mutableStateOf(false) }
+    var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
+    val latestQuery by rememberUpdatedState(state.query)
+    val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
+        GlassTopAppBarDefaults.defaultScrollBehavior()
+    } else {
+        M3GlassScrollBehavior(TopAppBarDefaults.enterAlwaysScrollBehavior())
+    }
+
+    val shouldLoadMore by remember {
+        derivedStateOf {
+            val totalCount = listState.layoutInfo.totalItemsCount
+            val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
+            totalCount > 0 && lastVisible >= totalCount - 3
+        }
+    }
+
+    LaunchedEffect(state.query) {
+        if (state.query != queryInput) {
+            queryInput = state.query
+        }
+    }
+
+    LaunchedEffect(state.showScopeSheet, state.isSourceScope) {
+        if (state.showScopeSheet) {
+            scopeSheetTab = if (state.isSourceScope) 1 else 0
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        snapshotFlow { queryInput }
+            .distinctUntilChanged()
+            .debounce(200)
+            .filter { it != latestQuery }
+            .collect { newQuery ->
+                viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
+            }
+    }
+
+    LaunchedEffect(
+        shouldLoadMore,
+        state.isSearching,
+        state.hasMore,
+        state.isManualStop,
+        state.showSuggestions,
+        searchExpanded,
+    ) {
+        if (
+            shouldLoadMore &&
+            !state.isSearching &&
+            state.hasMore &&
+            !state.isManualStop &&
+            !state.showSuggestions &&
+            !searchExpanded
+        ) {
+            viewModel.onIntent(SearchIntent.LoadMore)
+        }
+    }
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is SearchEffect.OpenBookInfo -> {
+                    onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
+                }
+
+                SearchEffect.OpenSourceManage -> onOpenSourceManage()
+                is SearchEffect.ShowMessage -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    val submitSearch: (String) -> Unit = { rawQuery ->
+        val normalized = rawQuery.trim()
+        if (normalized.isNotBlank()) {
+            queryInput = normalized
+            if (normalized != state.query) {
+                viewModel.onIntent(SearchIntent.UpdateQuery(normalized))
+            }
+            viewModel.onIntent(SearchIntent.SubmitSearch)
+        }
+    }
+    val searchLabel = stringResource(R.string.search_book_key)
+    val showResultCountCard = state.committedQuery.isNotBlank() || state.isSearching
+    val showSourceProgressCard = state.totalSources > 0
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                GlassMediumFlexibleTopAppBar(
+                    title = stringResource(R.string.search),
+                    navigationIcon = {
+                        TopBarNavigationButton(
+                            onClick = onBack,
+                            imageVector = AppIcons.Back
+                        )
+                    },
+                    actions = {
+                        if (!searchExpanded) {
+                            TopBarActionButton(
+                                onClick = {
+                                    viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true))
+                                },
+                                imageVector = AppIcons.Filter
+                            )
+                            TopBarActionButton(
+                                onClick = {
+                                    viewModel.onIntent(SearchIntent.OpenSourceManage)
+                                },
+                                imageVector = AppIcons.Settings,
+                            )
+                        }
+                    },
+                    scrollBehavior = scrollBehavior
+                )
+
+                Spacer(modifier = Modifier.height(8.dp))
+                AppSearchBar(
+                    modifier = Modifier.fillMaxWidth(),
+                    query = queryInput,
+                    onQueryChange = { queryInput = it },
+                    onSearch = submitSearch,
+                    expanded = searchExpanded,
+                    onExpandedChange = { searchExpanded = it },
+                    label = searchLabel,
+                    placeholder = { Text(text = searchLabel) },
+                    leadingIcon = {
+                        TopBarActionButton(
+                            onClick = {
+                                if (searchExpanded) {
+                                    searchExpanded = false
+                                }
+                            },
+                            imageVector = if (searchExpanded) AppIcons.Back else AppIcons.Search,
+                            contentDescription = if (searchExpanded) stringResource(R.string.search) else stringResource(
+                                R.string.search
+                            )
+                        )
+                    },
+                    trailingIcon = {
+                        Row(verticalAlignment = Alignment.CenterVertically) {
+                            if (queryInput.isNotEmpty()) {
+                                TopBarActionButton(
+                                    onClick = {
+                                        queryInput = ""
+                                        viewModel.onIntent(SearchIntent.UpdateQuery(""))
+                                    },
+                                    imageVector = Icons.Default.Close,
+                                    contentDescription = stringResource(R.string.clear)
+                                )
+                            }
+                            TopBarAnimatedActionButton(
+                                checked = state.isPrecisionSearch,
+                                onCheckedChange = { checked ->
+                                    viewModel.onIntent(SearchIntent.TogglePrecision(checked))
+                                },
+                                iconChecked = AppIcons.PrecisionSearch,
+                                iconUnchecked = AppIcons.UnPrecisionSearch,
+                                activeText = stringResource(R.string.precision_search),
+                                inactiveText = stringResource(R.string.search),
+                            )
+                            if (searchExpanded) {
+                                TopBarActionButton(
+                                    onClick = {
+                                        viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true))
+                                    },
+                                    imageVector = AppIcons.Filter,
+                                    contentDescription = stringResource(R.string.search_select_group)
+                                )
+                                TopBarActionButton(
+                                    onClick = {
+                                        viewModel.onIntent(SearchIntent.OpenSourceManage)
+                                    },
+                                    imageVector = AppIcons.Settings,
+                                    contentDescription = stringResource(R.string.book_source_manage)
+                                )
+                            }
+                        }
+                    },
+                ) {
+                    SearchSuggestionPanel(
+                        state = state,
+                        onUseHistory = { keyword ->
+                            queryInput = keyword
+                            viewModel.onIntent(SearchIntent.UseHistoryKeyword(keyword))
+                            searchExpanded = false
+                        },
+                        onDeleteHistory = { viewModel.onIntent(SearchIntent.DeleteHistory(it)) },
+                        onOpenBook = {
+                            viewModel.onIntent(SearchIntent.OpenBookshelfBook(it))
+                            searchExpanded = false
+                        },
+                        onClearHistory = {
+                            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(true))
+                        },
+                        modifier = Modifier.fillMaxSize(),
+                    )
+                }
+            }
+        },
+        floatingActionButton = {
+            val showFab = state.isSearching || (state.committedQuery.isNotBlank() && state.hasMore)
+            if (showFab) {
+                AppFloatingActionButton(
+                    onClick = {
+                        if (state.isSearching) {
+                            viewModel.onIntent(SearchIntent.StopSearch)
+                        } else {
+                            viewModel.onIntent(SearchIntent.LoadMore)
+                        }
+                    }
+                ) {
+                    AppIcon(
+                        imageVector = if (state.isSearching) Icons.Default.Stop else Icons.Default.PlayArrow,
+                        contentDescription = if (state.isSearching) stringResource(R.string.stop) else stringResource(R.string.start),
+                    )
+                }
+            }
+        }
+    ) { paddingValues ->
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(paddingValues)
+        ) {
+            if (showResultCountCard || showSourceProgressCard) {
+                Spacer(modifier = Modifier.height(6.dp))
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 36.dp),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    if (showResultCountCard) {
+                        TextCard(
+                            modifier = Modifier.weight(1f),
+                            icon = AppIcons.Search,
+                            text = "搜索结果 ${state.results.size}"
+                        )
+                    }
+                    if (showSourceProgressCard) {
+                        TextCard(
+                            modifier = Modifier.weight(1f),
+                            icon = AppIcons.Filter,
+                            text = "已检索源 ${state.processedSources}/${state.totalSources}"
+                        )
+                    }
+                }
+            }
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            if (state.results.isEmpty()) {
+                Box(
+                    modifier = Modifier.fillMaxSize(),
+                    contentAlignment = Alignment.Center
+                ) {
+                    SearchResultFooter(
+                        isSearching = state.isSearching,
+                        hasMore = state.hasMore,
+                        hasResult = false,
+                        committedQuery = state.committedQuery,
+                        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                    )
+                }
+            } else {
+                LazyColumn(
+                    modifier = Modifier.fillMaxSize(),
+                    state = listState,
+                    verticalArrangement = Arrangement.spacedBy(6.dp)
+                ) {
+                    items(items = state.results, key = { it.bookUrl }) { book ->
+                        val shelfStateFlow = remember(book.bookUrl) {
+                            viewModel.getBookShelfStateFlow(book)
+                        }
+                        SearchBookListItem(
+                            book = book,
+                            shelfState = shelfStateFlow,
+                            onClick = {
+                                viewModel.onIntent(SearchIntent.OpenSearchBook(book))
+                            }
+                        )
+                    }
+
+                    item {
+                        SearchResultFooter(
+                            isSearching = state.isSearching,
+                            hasMore = state.hasMore,
+                            hasResult = true,
+                            committedQuery = state.committedQuery,
+                            onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    AppAlertDialog(
+        show = state.showClearHistoryDialog,
+        onDismissRequest = {
+            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
+        },
+        title = stringResource(R.string.draw),
+        text = stringResource(R.string.sure_clear_search_history),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { viewModel.onIntent(SearchIntent.ConfirmClearHistory) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = {
+            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
+        },
+    )
+
+    AppAlertDialog(
+        data = state.emptyScopeAction,
+        onDismissRequest = {
+            viewModel.onIntent(SearchIntent.DismissEmptyScopeAction)
+        },
+        title = stringResource(R.string.draw),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { viewModel.onIntent(SearchIntent.ConfirmEmptyScopeAction) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { viewModel.onIntent(SearchIntent.DismissEmptyScopeAction) },
+        content = {
+            Text(
+                text = if (it.wasPrecisionSearch) {
+                    "${it.scopeDisplay}分组搜索结果为空,是否关闭精准搜索?"
+                } else {
+                    "${it.scopeDisplay}分组搜索结果为空,是否切换到全部分组?"
+                }
+            )
+        }
+    )
+
+    AppModalBottomSheet(
+        show = state.showScopeSheet,
+        onDismissRequest = { viewModel.onIntent(SearchIntent.SetScopeSheetVisible(false)) },
+        title = stringResource(R.string.search_select_group),
+    ) {
+        Column {
+            SelectionItemCard(
+                title = stringResource(R.string.all_source),
+                isSelected = state.isAllScope,
+                containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
+                inSelectionMode = true,
+                onToggleSelection = {
+                    viewModel.onIntent(SearchIntent.SelectAllScope)
+                }
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+            AppTabRow(
+                tabTitles = listOf(
+                    stringResource(R.string.group),
+                    stringResource(R.string.book_source),
+                ),
+                selectedTabIndex = scopeSheetTab,
+                onTabSelected = { scopeSheetTab = it },
+            )
+            Spacer(modifier = Modifier.height(6.dp))
+
+            if (scopeSheetTab == 0) {
+                LazyColumn(
+                    verticalArrangement = Arrangement.spacedBy(4.dp)
+                ) {
+                    items(state.enabledGroups, key = { it }) {
+                        val selected = !state.isSourceScope && state.scopeDisplayNames.contains(it)
+                        SelectionItemCard(
+                            title = it,
+                            isSelected = selected,
+                            containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
+                            inSelectionMode = true,
+                            onToggleSelection = {
+                                viewModel.onIntent(SearchIntent.ToggleScopeGroup(it))
+                            }
+                        )
+                    }
+                }
+            } else {
+                if (state.enabledSources.isEmpty()) {
+                    Text(
+                        text = stringResource(R.string.search_empty),
+                        style = LegadoTheme.typography.bodyMedium,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        textAlign = TextAlign.Center,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(vertical = 12.dp),
+                    )
+                } else {
+                    LazyColumn(
+                        verticalArrangement = Arrangement.spacedBy(4.dp)
+                    ) {
+                        items(state.enabledSources, key = { it.bookSourceUrl }) {
+                            val selected = state.selectedScopeSourceUrls.contains(it.bookSourceUrl)
+                            SelectionItemCard(
+                                title = it.bookSourceName,
+                                subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
+                                containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
+                                isSelected = selected,
+                                inSelectionMode = true,
+                                onToggleSelection = {
+                                    viewModel.onIntent(SearchIntent.ToggleScopeSource(it))
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+
+            Spacer(modifier = Modifier.height(20.dp))
+        }
+    }
+}
+
+@Composable
+private fun SearchSuggestionPanel(
+    state: SearchUiState,
+    onUseHistory: (String) -> Unit,
+    onDeleteHistory: (SearchKeyword) -> Unit,
+    onOpenBook: (BookShelfItem) -> Unit,
+    onClearHistory: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier = modifier,
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+        contentPadding = adaptiveContentPadding(
+            top = 8.dp,
+            bottom = 8.dp
+        )
+    ) {
+        if (state.bookshelfHints.isNotEmpty()) {
+            item {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    AppIcon(Icons.Default.Book, contentDescription = null)
+                    Spacer(modifier = Modifier.width(6.dp))
+                    AppText(
+                        text = stringResource(R.string.bookshelf),
+                        style = LegadoTheme.typography.titleSmall,
+                    )
+                }
+            }
+
+            items(state.bookshelfHints, key = { it.bookUrl }) { book ->
+                SelectionItemCard(
+                    modifier = Modifier.animateItem(),
+                    title = book.name,
+                    subtitle = book.author,
+                    onToggleSelection = { onOpenBook(book) }
+                )
+            }
+        }
+
+        item {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.SpaceBetween,
+            ) {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    AppIcon(AppIcons.History, contentDescription = null)
+                    Spacer(modifier = Modifier.width(6.dp))
+                    AppText(
+                        text = stringResource(R.string.searchHistory),
+                        style = LegadoTheme.typography.titleSmall,
+                    )
+                }
+
+                if (state.history.isNotEmpty()) {
+                    SmallTextButton(
+                        onClick = onClearHistory,
+                        text = stringResource(R.string.clear_all),
+                        imageVector = Icons.Default.Close
+                    )
+                }
+            }
+        }
+
+        if (state.history.isEmpty()) {
+            item {
+                Box(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 12.dp),
+                    contentAlignment = Alignment.Center
+                ) {
+                    Text(
+                        text = stringResource(R.string.search_empty),
+                        style = LegadoTheme.typography.bodyMedium,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        textAlign = TextAlign.Center,
+                    )
+                }
+            }
+        } else {
+            items(state.history, key = { it.word }) { history ->
+                SelectionItemCard(
+                    modifier = Modifier.animateItem(),
+                    title = history.word,
+                    onToggleSelection = { onUseHistory(history.word) },
+                    trailingAction = {
+                        SmallIconButton(
+                            onClick = { onDeleteHistory(history) },
+                            imageVector = Icons.Default.Close,
+                            contentDescription = stringResource(R.string.delete)
+                        )
+                    }
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun SearchResultFooter(
+    isSearching: Boolean,
+    hasMore: Boolean,
+    hasResult: Boolean,
+    committedQuery: String,
+    onLoadMore: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(vertical = 24.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        when {
+            isSearching -> {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    CircularProgressIndicator(modifier = Modifier.width(18.dp), strokeWidth = 2.dp)
+                    Spacer(modifier = Modifier.width(8.dp))
+                    AppText(text = stringResource(R.string.is_loading))
+                }
+            }
+
+            !hasResult && committedQuery.isNotBlank() -> {
+                Text(
+                    text = stringResource(R.string.search_empty),
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+
+            hasMore -> {
+                Text(
+                    text = stringResource(R.string.search_empty),
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+
+            else -> {
+                Text(
+                    text = stringResource(R.string.search_empty),
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}

+ 219 - 0
app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt

@@ -0,0 +1,219 @@
+package io.legado.app.ui.widget.components.book
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.model.BookShelfState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.cover.Cover
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.flow.Flow
+
+@Composable
+fun SearchBookListItem(
+    book: SearchBook,
+    shelfState: Flow<BookShelfState>,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val currentShelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
+
+    val badgeContent: (@Composable RowScope.() -> Unit)? = when (currentShelfState) {
+        BookShelfState.IN_SHELF -> {
+            {
+                androidx.compose.material3.Icon(
+                    imageVector = Icons.Default.Check,
+                    contentDescription = "已在书架",
+                    modifier = Modifier.size(12.dp),
+                )
+            }
+        }
+
+        BookShelfState.SAME_NAME_AUTHOR -> {
+            {
+                androidx.compose.material3.Icon(
+                    imageVector = Icons.Default.Warning,
+                    contentDescription = "同名书籍",
+                    modifier = Modifier.size(12.dp),
+                )
+            }
+        }
+
+        BookShelfState.NOT_IN_SHELF -> null
+    }
+
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(horizontal = 16.dp, vertical = 8.dp)
+    ) {
+        Cover(
+            path = book.coverUrl,
+            modifier = Modifier.width(72.dp),
+            badgeContent = badgeContent,
+        )
+
+        Spacer(modifier = Modifier.width(8.dp))
+
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .align(Alignment.CenterVertically)
+        ) {
+            AppText(
+                text = book.name,
+                style = LegadoTheme.typography.titleSmall,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+
+            Row {
+                AppText(
+                    text = book.author,
+                    style = LegadoTheme.typography.bodySmall,
+                    maxLines = 1,
+                )
+
+                val latestChapter = book.latestChapterTitle
+                if (!latestChapter.isNullOrEmpty()) {
+                    AppText(
+                        text = " • ",
+                        style = LegadoTheme.typography.bodySmall,
+                        color = Color.Gray,
+                        maxLines = 1,
+                    )
+
+                    AppText(
+                        text = "最新: $latestChapter",
+                        style = LegadoTheme.typography.bodySmall,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+
+            Spacer(modifier = Modifier.height(4.dp))
+
+            val intro = book.intro?.replace("\\s+".toRegex(), "") ?: ""
+            if (intro.isNotEmpty()) {
+                AppText(
+                    text = intro,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = Color.Gray,
+                    maxLines = 2,
+                    minLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+
+            val kinds = book.getKindList()
+            if (kinds.isNotEmpty()) {
+                Spacer(modifier = Modifier.height(4.dp))
+                Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+                    kinds.forEach { kind ->
+                        SearchBookTagChip(text = kind)
+                        Spacer(modifier = Modifier.width(6.dp))
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun SearchBookGridItem(
+    book: SearchBook,
+    shelfState: Flow<BookShelfState>,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val currentShelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
+
+    val badgeText: String? = when (currentShelfState) {
+        BookShelfState.IN_SHELF -> "已在书架"
+        BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
+        BookShelfState.NOT_IN_SHELF -> null
+    }
+
+    val badgeContent: (@Composable RowScope.() -> Unit)? = if (!badgeText.isNullOrBlank()) {
+        {
+            AppText(
+                text = badgeText,
+                style = LegadoTheme.typography.labelSmall.copy(
+                    fontWeight = FontWeight.Bold,
+                    fontSize = 9.sp,
+                )
+            )
+        }
+    } else {
+        null
+    }
+
+    Column(
+        modifier = modifier
+            .width(IntrinsicSize.Min)
+            .clickable(onClick = onClick)
+            .padding(4.dp)
+    ) {
+        Cover(
+            path = book.coverUrl,
+            modifier = Modifier
+                .fillMaxWidth()
+                .aspectRatio(12 / 17f),
+            badgeContent = badgeContent,
+        )
+
+        Spacer(modifier = Modifier.height(4.dp))
+
+        AppText(
+            text = book.name,
+            style = LegadoTheme.typography.bodySmall,
+            fontWeight = FontWeight.Bold,
+            maxLines = 2,
+            overflow = TextOverflow.Ellipsis,
+        )
+    }
+}
+
+@Composable
+private fun SearchBookTagChip(text: String) {
+    Surface(
+        color = LegadoTheme.colorScheme.cardContainer,
+        shape = RoundedCornerShape(4.dp),
+    ) {
+        AppText(
+            text = text,
+            modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+            style = LegadoTheme.typography.labelSmall,
+            color = LegadoTheme.colorScheme.onCardContainer,
+        )
+    }
+}