[优化] 重写搜索界面

This commit is contained in:
HapeLee
2026-04-18 02:25:18 +08:00
parent c6ed112aa3
commit 7f8e48fa83
5 changed files with 1144 additions and 0 deletions
@@ -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()
}
}
}
@@ -0,0 +1,7 @@
package io.legado.app.ui.book.search
data class BookKey(
val name: String,
val author: String,
val url: String?
)
@@ -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
}
@@ -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,
)
}
}
}
}
@@ -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,
)
}
}