增加搜素分源布局

This commit is contained in:
HapeLee
2026-05-27 01:00:58 +08:00
parent e95665b8a0
commit df2aaff0e2
7 changed files with 522 additions and 169 deletions
@@ -6,6 +6,7 @@ import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CancellationException
@@ -30,7 +31,7 @@ data class BookSearchRequest(
val keyword: String,
val page: Int,
val scope: BookSearchScope,
val precision: Boolean,
val matchMode: MatchMode,
val concurrency: Int,
val types: Set<Int>? = null,
)
@@ -103,7 +104,7 @@ class SearchBooksUseCase(
throw NoStackTraceException("可搜索书源为空")
}
val merger = SearchResultMerger(keyword, request.precision)
val merger = SearchResultMerger(keyword, request.matchMode)
val concurrency = request.concurrency.coerceAtLeast(1)
var hasMore = false
var processedSources = 0
@@ -116,7 +117,7 @@ class SearchBooksUseCase(
.flatMapMerge(concurrency) { searchableSource ->
flow {
control.awaitResumed()
emit(searchSource(searchableSource, keyword, request.page, request.precision))
emit(searchSource(searchableSource, keyword, request.page, request.matchMode))
}.flowOn(Dispatchers.IO)
}
.collect { result ->
@@ -177,7 +178,7 @@ class SearchBooksUseCase(
searchableSource: SearchableSource,
keyword: String,
page: Int,
precision: Boolean,
matchMode: MatchMode,
): SourceSearchResult {
return try {
val source = searchableSource.source
@@ -191,7 +192,7 @@ class SearchBooksUseCase(
keyword,
page,
filter = { name, author ->
!precision ||
matchMode == MatchMode.DEFAULT ||
name.contains(keyword, ignoreCase = true) ||
author.contains(keyword, ignoreCase = true)
}
@@ -222,7 +223,7 @@ class SearchBooksUseCase(
private class SearchResultMerger(
private val keyword: String,
private val precision: Boolean,
private val matchMode: MatchMode,
) {
private companion object {
const val MAX_RETAINED_SEARCH_RESULTS = 1000
@@ -267,9 +268,13 @@ class SearchBooksUseCase(
book.name.equals(keyword, ignoreCase = true) ||
book.author.equals(keyword, ignoreCase = true) -> equalBooks
book.name.contains(keyword, ignoreCase = true) ||
book.author.contains(keyword, ignoreCase = true) -> containsBooks
!precision -> otherBooks
else -> null
book.author.contains(
keyword,
ignoreCase = true
) -> if (matchMode == MatchMode.EXACT) null else containsBooks
matchMode != MatchMode.DEFAULT -> null
else -> otherBooks
}
}
@@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -27,8 +25,9 @@ import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.MediumPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -68,9 +67,9 @@ fun ScopeSelectSheet(
title = title,
endAction = onConfirm?.let {
{
MediumIconButton(
MediumPlainButton(
onClick = it,
imageVector = Icons.Default.Check
icon = AppIcons.Settings
)
}
}
@@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() {
},
onOpenSourceManage = {
startActivity<BookSourceActivity>()
}
},
)
}
@@ -5,7 +5,10 @@ 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.domain.model.MatchMode
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class SearchResultItemUi(
@@ -17,17 +20,17 @@ data class SearchResultItemUi(
data class SearchUiState(
val query: String = "",
val committedQuery: String = "",
val results: List<SearchResultItemUi> = emptyList(),
val history: List<SearchKeyword> = emptyList(),
val bookshelfHints: List<BookShelfItem> = emptyList(),
val enabledGroups: List<String> = emptyList(),
val enabledSources: List<BookSourcePart> = emptyList(),
val results: ImmutableList<SearchResultItemUi> = persistentListOf(),
val history: ImmutableList<SearchKeyword> = persistentListOf(),
val bookshelfHints: ImmutableList<BookShelfItem> = persistentListOf(),
val enabledGroups: ImmutableList<String> = persistentListOf(),
val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
val scopeDisplay: String = "",
val scopeDisplayNames: List<String> = emptyList(),
val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
val selectedScopeSourceUrls: Set<String> = emptySet(),
val isAllScope: Boolean = true,
val isSourceScope: Boolean = false,
val isPrecisionSearch: Boolean = false,
val matchMode: MatchMode = MatchMode.DEFAULT,
val isSearching: Boolean = false,
val isManualStop: Boolean = false,
val hasMore: Boolean = true,
@@ -35,17 +38,24 @@ data class SearchUiState(
val totalSources: Int = 0,
val selectedSourceTypes: Set<Int> = emptySet(),
val showScopeSheet: Boolean = false,
val showTypeSheet: Boolean = false,
val showSettingsSheet: Boolean = false,
val showClearHistoryDialog: Boolean = false,
val showSuggestions: Boolean = true,
val emptyScopeAction: SearchEmptyScopeAction? = null,
val savedScrollIndex: Int = 0,
val savedScrollOffset: Int = 0,
val expandedSourceUrl: String? = null,
val expandedSourceName: String? = null,
val expandedSourceBooks: ImmutableList<SearchBook> = persistentListOf(),
val expandedSourceLoading: Boolean = false,
val expandedSourceEnd: Boolean = false,
val expandedSourceError: String? = null,
val expandedSourcePage: Int = 1,
)
data class SearchEmptyScopeAction(
val scopeDisplay: String,
val wasPrecisionSearch: Boolean,
val wasMatchMode: MatchMode,
)
sealed interface SearchIntent {
@@ -60,17 +70,22 @@ sealed interface SearchIntent {
data class UseHistoryKeyword(val keyword: String) : SearchIntent
data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent
data object DismissExpandedSource : SearchIntent
data object LoadMoreExpandedSource : SearchIntent
data class OpenExpandedSourceBook(val book: SearchBook, val sharedCoverKey: String?) :
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 class SetTypeSheetVisible(val visible: Boolean) : SearchIntent
data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
data class ToggleSourceType(val type: Int) : 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 class SetMatchMode(val mode: MatchMode) : SearchIntent
data object ConfirmEmptyScopeAction : SearchIntent
data object DismissEmptyScopeAction : SearchIntent
data object OpenSourceManage : SearchIntent
@@ -20,8 +20,10 @@ 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.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
@@ -54,6 +56,7 @@ import io.legado.app.R
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.domain.model.MatchMode
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.ui.theme.LegadoTheme
@@ -63,12 +66,12 @@ 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.AppScaffold
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookListItem
import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
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.button.SmallPlainButton
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.icon.AppIcon
@@ -76,11 +79,11 @@ import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
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.utils.toastOnUi
@@ -100,6 +103,8 @@ fun SearchScreen(
) {
val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle()
val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
val isSourceGroupedMode = searchLayoutMode == 1
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
@@ -149,13 +154,15 @@ fun SearchScreen(
state.hasMore,
state.isManualStop,
state.showSuggestions,
isSourceGroupedMode,
) {
if (
shouldLoadMore &&
!state.isSearching &&
state.hasMore &&
!state.isManualStop &&
!state.showSuggestions
!state.showSuggestions &&
!isSourceGroupedMode
) {
viewModel.onIntent(SearchIntent.LoadMore)
}
@@ -261,32 +268,15 @@ fun SearchScreen(
)
},
actions = {
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
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),
)
TopBarAnimatedActionButton(
checked = state.selectedSourceTypes.isNotEmpty(),
checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(),
onCheckedChange = {
viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true))
viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
},
iconChecked = Icons.Default.Layers,
iconUnchecked = Icons.Default.Layers,
activeText = "搜素类型",
inactiveText = "搜素类型",
iconChecked = AppIcons.Settings,
iconUnchecked = AppIcons.Settings,
activeText = stringResource(R.string.setting),
inactiveText = stringResource(R.string.setting),
)
TopBarAnimatedActionButton(
checked = !state.isAllScope,
@@ -296,7 +286,7 @@ fun SearchScreen(
iconChecked = AppIcons.Filter,
iconUnchecked = AppIcons.Filter,
activeText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen)
)
},
scrollBehavior = scrollBehavior
@@ -315,12 +305,13 @@ fun SearchScreen(
placeholder = searchLabel,
trailingIcon = {
if (queryInput.isNotEmpty()) {
TopBarActionButton(
SmallPlainButton(
modifier = Modifier.padding(horizontal = 8.dp),
onClick = {
queryInput = ""
viewModel.onIntent(SearchIntent.UpdateQuery(""))
},
imageVector = AppIcons.Close,
icon = AppIcons.Close,
contentDescription = stringResource(R.string.clear)
)
}
@@ -394,52 +385,124 @@ fun SearchScreen(
}
if (state.results.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
itemsIndexed(
items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
)
SearchBookListItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
val sourceGroupedResults = remember(state.results) {
state.results
.groupBy { it.book.origin }
.map { (origin, books) ->
SourceGroup(
origin = origin,
sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() }
?: origin,
items = books
)
}
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
AnimatedContent(
targetState = isSourceGroupedMode,
label = "SearchLayoutTransition",
modifier = Modifier.fillMaxSize(),
) { isSourceGrouped ->
if (isSourceGrouped) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
sourceGroupedResults.forEachIndexed { groupIndex, group ->
item(key = "header_${group.origin}") {
SearchSourceSection(
sourceName = group.sourceName,
items = group.items,
onClickBook = { book, coverKey ->
viewModel.onIntent(
SearchIntent.OpenSearchBook(
book,
coverKey
)
)
},
onLongClickBook = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
onViewAll = {
viewModel.onIntent(
SearchIntent.ExpandSource(
group.origin,
group.sourceName
)
)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sourceSectionIndex = groupIndex,
)
}
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(
items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
)
SearchBookListItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
}
}
}
}
}
@@ -502,7 +565,7 @@ fun SearchScreen(
},
title = stringResource(R.string.draw),
textProvider = {
if (wasPrecisionSearch) {
if (wasMatchMode == MatchMode.EXACT) {
stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
} else {
stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
@@ -525,46 +588,101 @@ fun SearchScreen(
sources = state.enabledSources,
selectedSources = state.selectedScopeSourceUrls,
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope
isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
)
AppModalBottomSheet(
show = state.showTypeSheet,
onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) },
title = "搜素类型",
show = state.showSettingsSheet,
onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
title = stringResource(R.string.setting),
) {
Column {
SelectionItemCard(
title = stringResource(R.string.all),
isSelected = state.selectedSourceTypes.isEmpty(),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
if (state.selectedSourceTypes.isNotEmpty()) {
state.selectedSourceTypes.forEach {
viewModel.onIntent(SearchIntent.ToggleSourceType(it))
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CompactDropdownSettingItem(
title = stringResource(R.string.layout_mode),
selectedValue = searchLayoutMode.toString(),
displayEntries = arrayOf(
stringResource(R.string.search_layout_source_grouped),
stringResource(R.string.search_layout_list)
),
entryValues = arrayOf("1", "0"),
imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted,
onValueChange = { newValue ->
if (newValue.toInt() != searchLayoutMode) {
viewModel.toggleSearchLayout()
}
}
)
Spacer(modifier = Modifier.height(8.dp))
CompactDropdownSettingItem(
title = stringResource(R.string.precision_search),
selectedValue = state.matchMode.value.toString(),
displayEntries = arrayOf(
stringResource(R.string.precision_search),
stringResource(R.string.search)
),
entryValues = arrayOf(
MatchMode.EXACT.value.toString(),
MatchMode.DEFAULT.value.toString()
),
imageVector = if (state.matchMode == MatchMode.EXACT) AppIcons.PrecisionSearch else AppIcons.UnPrecisionSearch,
onValueChange = { newValue ->
val mode = MatchMode.of(newValue.toInt())
if (mode != state.matchMode) {
viewModel.onIntent(SearchIntent.SetMatchMode(mode))
}
}
)
listOf(
0 to stringResource(R.string.noval),
2 to stringResource(R.string.manga),
1 to stringResource(R.string.audio),
).forEach { (type, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(Icons.Default.Layers, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "搜索类型",
style = LegadoTheme.typography.titleSmall
)
}
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
SelectionItemCard(
title = label,
isSelected = state.selectedSourceTypes.contains(type),
title = stringResource(R.string.all),
isSelected = state.selectedSourceTypes.isEmpty(),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
viewModel.onIntent(SearchIntent.ToggleSourceType(type))
if (state.selectedSourceTypes.isNotEmpty()) {
state.selectedSourceTypes.forEach {
viewModel.onIntent(SearchIntent.ToggleSourceType(it))
}
}
}
)
Spacer(modifier = Modifier.height(4.dp))
listOf(
0 to stringResource(R.string.noval),
2 to stringResource(R.string.manga),
1 to stringResource(R.string.audio),
).forEach { (type, label) ->
SelectionItemCard(
title = label,
isSelected = state.selectedSourceTypes.contains(type),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
viewModel.onIntent(SearchIntent.ToggleSourceType(type))
}
)
}
}
Spacer(modifier = Modifier.height(20.dp))
@@ -588,6 +706,24 @@ fun SearchScreen(
viewModel.onAddToShelf(book)
},
)
ExpandedSourceSheet(
show = state.expandedSourceUrl != null,
sourceName = state.expandedSourceName ?: "",
books = state.expandedSourceBooks,
isLoading = state.expandedSourceLoading,
isEnd = state.expandedSourceEnd,
errorMsg = state.expandedSourceError,
onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
onBookClick = { book, coverKey ->
viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
},
onBookLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
)
}
private data class SearchFloatingSummary(
@@ -653,10 +789,10 @@ private fun SearchSuggestionPanel(
}
if (state.history.isNotEmpty()) {
SmallTextButton(
SmallPlainButton(
onClick = onClearHistory,
text = stringResource(R.string.clear_all),
imageVector = Icons.Default.Close
icon = Icons.Default.Close
)
}
}
@@ -685,9 +821,9 @@ private fun SearchSuggestionPanel(
title = history.word,
onToggleSelection = { onUseHistory(history.word) },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onDeleteHistory(history) },
imageVector = Icons.Default.Close,
icon = Icons.Default.Close,
contentDescription = stringResource(R.string.delete)
)
}
@@ -747,3 +883,76 @@ private fun SearchResultFooter(
}
}
}
private data class SourceGroup(
val origin: String,
val sourceName: String,
val items: List<SearchResultItemUi>,
)
@Composable
private fun ExpandedSourceSheet(
show: Boolean,
sourceName: String,
books: List<SearchBook>,
isLoading: Boolean,
isEnd: Boolean,
errorMsg: String?,
onDismiss: () -> Unit,
onLoadMore: () -> Unit,
onBookClick: (SearchBook, String?) -> Unit,
onBookLongClick: ((SearchBook, String?) -> Unit)? = null,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismiss,
title = sourceName,
) {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val total = listState.layoutInfo.totalItemsCount
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 3
}
}
LaunchedEffect(shouldLoadMore, isLoading, isEnd) {
if (shouldLoadMore && !isLoading && !isEnd) {
onLoadMore()
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 8.dp,
bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = books,
key = { it.bookUrl },
) { book ->
SearchBookListItem(
book = book,
shelfState = BookShelfState.NOT_IN_SHELF,
onClick = { onBookClick(book, null) },
onLongClick = onBookLongClick,
)
}
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isEnd,
onRetry = onLoadMore,
)
}
}
}
}
@@ -2,50 +2,73 @@ package io.legado.app.ui.book.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.domain.usecase.AddToBookshelfUseCase
import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.putPrefBoolean
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import splitties.init.appCtx
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel(
private val repository: SearchRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val searchBooksUseCase: SearchBooksUseCase,
private val exploreBooksUseCase: ExploreBooksUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase,
private val localPreferencesRepository: LocalPreferencesRepository,
) : ViewModel() {
val searchLayoutMode = localPreferencesRepository
.getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
fun toggleSearchLayout() {
viewModelScope.launch {
val newMode = if (searchLayoutMode.value == 0) 1 else 0
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.SEARCH_LAYOUT_MODE, newMode
)
}
}
private val matchModeFlow = localPreferencesRepository
.getPreference(LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value)
.distinctUntilChanged()
.map { MatchMode.of(it) }
private val _uiState = MutableStateFlow(
SearchUiState(
isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
scopeDisplay = SearchScope(AppConfig.searchScope).display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
isAllScope = SearchScope(AppConfig.searchScope).isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
)
@@ -72,6 +95,7 @@ class SearchViewModel(
observeBookshelf()
observeQueryHistory()
observeQueryBookshelfHints()
observeMatchMode()
}
fun onAddToShelf(book: SearchBook) {
@@ -153,8 +177,8 @@ class SearchViewModel(
_uiState.update { it.copy(showScopeSheet = intent.visible) }
}
is SearchIntent.SetTypeSheetVisible -> {
_uiState.update { it.copy(showTypeSheet = intent.visible) }
is SearchIntent.SetSettingsSheetVisible -> {
_uiState.update { it.copy(showSettingsSheet = intent.visible) }
}
is SearchIntent.ToggleSourceType -> {
@@ -184,9 +208,12 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope)
}
is SearchIntent.TogglePrecision -> {
appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled)
_uiState.update { it.copy(isPrecisionSearch = intent.enabled) }
is SearchIntent.SetMatchMode -> {
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
)
}
restartCommittedSearchIfNeeded()
}
@@ -197,6 +224,61 @@ class SearchViewModel(
SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
is SearchIntent.ExpandSource -> {
_uiState.update {
it.copy(
expandedSourceUrl = intent.sourceUrl,
expandedSourceName = intent.sourceName,
expandedSourceBooks = persistentListOf(),
expandedSourceLoading = true,
expandedSourceEnd = false,
expandedSourceError = null,
expandedSourcePage = 1,
)
}
loadExpandedSourcePage(intent.sourceUrl, page = 1)
}
SearchIntent.DismissExpandedSource -> {
_uiState.update {
it.copy(
expandedSourceUrl = null,
expandedSourceName = null,
expandedSourceBooks = persistentListOf(),
expandedSourceLoading = false,
expandedSourceEnd = false,
expandedSourceError = null,
expandedSourcePage = 1,
)
}
}
SearchIntent.LoadMoreExpandedSource -> {
val state = _uiState.value
val sourceUrl = state.expandedSourceUrl ?: return
if (state.expandedSourceLoading || state.expandedSourceEnd) return
_uiState.update {
it.copy(
expandedSourceLoading = true,
expandedSourceError = null
)
}
loadExpandedSourcePage(sourceUrl, page = state.expandedSourcePage)
}
is SearchIntent.OpenExpandedSourceBook -> {
emitEffect(
SearchEffect.OpenBookInfo(
name = intent.book.name,
author = intent.book.author,
bookUrl = intent.book.bookUrl,
origin = intent.book.origin,
coverPath = intent.book.coverUrl,
sharedCoverKey = intent.sharedCoverKey,
)
)
}
is SearchIntent.SaveScrollState -> {
_uiState.update {
it.copy(
@@ -233,7 +315,7 @@ class SearchViewModel(
repository.enabledGroups
.catch { emit(emptyList()) }
.collect { groups ->
_uiState.update { it.copy(enabledGroups = groups) }
_uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
}
}
}
@@ -243,7 +325,7 @@ class SearchViewModel(
repository.enabledSources
.catch { emit(emptyList()) }
.collect { sources ->
_uiState.update { it.copy(enabledSources = sources) }
_uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
}
}
}
@@ -255,7 +337,7 @@ class SearchViewModel(
.collect { keys ->
bookshelfKeys.value = keys
_uiState.update { state ->
state.copy(results = state.results.withShelfState(keys))
state.copy(results = state.results.withShelfState(keys).toImmutableList())
}
}
}
@@ -269,7 +351,7 @@ class SearchViewModel(
.flatMapLatest { repository.searchHistory(it) }
.catch { emit(emptyList()) }
.collect { history ->
_uiState.update { it.copy(history = history) }
_uiState.update { it.copy(history = history.toImmutableList()) }
}
}
}
@@ -282,11 +364,19 @@ class SearchViewModel(
.flatMapLatest { repository.searchBookshelf(it) }
.catch { emit(emptyList()) }
.collect { books ->
_uiState.update { it.copy(bookshelfHints = books) }
_uiState.update { it.copy(bookshelfHints = books.toImmutableList()) }
}
}
}
private fun observeMatchMode() {
viewModelScope.launch {
matchModeFlow.collect { mode ->
_uiState.update { it.copy(matchMode = mode) }
}
}
}
private fun updateQuery(query: String, showSuggestions: Boolean) {
val currentState = _uiState.value
val isSameQuery = currentState.query == query
@@ -319,7 +409,7 @@ class SearchViewModel(
_uiState.update {
it.copy(
committedQuery = keyword,
results = emptyList(),
results = persistentListOf(),
isManualStop = false,
hasMore = true,
processedSources = 0,
@@ -362,7 +452,7 @@ class SearchViewModel(
keyword = keyword,
page = page,
scope = BookSearchScope(searchScope.toString()),
precision = _uiState.value.isPrecisionSearch,
matchMode = _uiState.value.matchMode,
concurrency = OtherConfig.threadCount,
types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
),
@@ -395,7 +485,7 @@ class SearchViewModel(
it.copy(
results = buildSearchResultItems(
shelf = bookshelfKeys.value,
),
).toImmutableList(),
processedSources = event.processedSources,
totalSources = event.totalSources,
)
@@ -407,7 +497,7 @@ class SearchViewModel(
val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction(
scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch,
wasMatchMode = state.matchMode,
)
} else {
null
@@ -441,7 +531,7 @@ class SearchViewModel(
it.copy(
query = "",
committedQuery = "",
results = emptyList(),
results = persistentListOf(),
processedSources = 0,
totalSources = 0,
isSearching = false,
@@ -497,9 +587,12 @@ class SearchViewModel(
val action = _uiState.value.emptyScopeAction ?: return
_uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasPrecisionSearch) {
appCtx.putPrefBoolean(PreferKey.precisionSearch, false)
_uiState.update { it.copy(isPrecisionSearch = false) }
if (action.wasMatchMode == MatchMode.EXACT) {
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
)
}
} else {
searchScope.update("")
syncScopeState()
@@ -523,7 +616,7 @@ class SearchViewModel(
_uiState.update {
it.copy(
scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames,
scopeDisplayNames = searchScope.displayNames.toImmutableList(),
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(),
@@ -571,6 +664,37 @@ class SearchViewModel(
}
}
private fun loadExpandedSourcePage(sourceUrl: String, page: Int) {
viewModelScope.launch {
val keyword = _uiState.value.committedQuery
try {
val result = exploreBooksUseCase.execute(
sourceUrl = sourceUrl,
moduleUrl = null,
args = null,
page = page,
key = keyword,
)
val newBooks = result.books
_uiState.update {
it.copy(
expandedSourceBooks = (it.expandedSourceBooks + newBooks).toImmutableList(),
expandedSourceLoading = false,
expandedSourceEnd = newBooks.isEmpty(),
expandedSourcePage = page + 1,
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
expandedSourceLoading = false,
expandedSourceError = e.message ?: "Unknown error",
)
}
}
}
}
private fun emitEffect(effect: SearchEffect) {
_effects.tryEmit(effect)
}
@@ -34,11 +34,9 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import io.legado.app.ui.widget.components.AppFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
@@ -57,20 +55,23 @@ import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchContentHistory
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton
import io.legado.app.ui.widget.components.button.SmallIconButton
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.button.SmallPlainButton
import io.legado.app.ui.widget.components.button.SmallToggleButton
import io.legado.app.ui.widget.components.button.ToggleStyle
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -302,14 +303,14 @@ fun SearchHistoryList(
color = LegadoTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center)
)
SmallAnimatedActionButton(
SmallToggleButton(
modifier = Modifier.align(Alignment.CenterEnd),
checked = onlyThisBook,
onCheckedChange = { onToggleScope() },
style = ToggleStyle.Tonal,
iconChecked = Icons.Default.Book,
iconUnchecked = Icons.Default.CollectionsBookmark,
activeText = "仅本书",
inactiveText = "所有记录"
icon = Icons.Default.CollectionsBookmark,
text = "仅本书"
)
}
@@ -338,9 +339,9 @@ fun SearchHistoryList(
Icon(Icons.Default.History, contentDescription = null)
},
trailingContent = {
SmallIconButton(
SmallPlainButton(
onClick = { onDeleteHistory(item) },
imageVector = Icons.Default.Close,
icon = Icons.Default.Close,
contentDescription = "删除"
)
},
@@ -361,7 +362,7 @@ fun SearchHistoryList(
MediumOutlinedButton(
onClick = onClearHistory,
modifier = Modifier.fillMaxWidth(0.6f),
imageVector = Icons.Outlined.DeleteSweep,
icon = Icons.Outlined.DeleteSweep,
text = "清除搜索历史"
)
}