增加搜素分源布局

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.data.entities.SearchBook
import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -30,7 +31,7 @@ data class BookSearchRequest(
val keyword: String, val keyword: String,
val page: Int, val page: Int,
val scope: BookSearchScope, val scope: BookSearchScope,
val precision: Boolean, val matchMode: MatchMode,
val concurrency: Int, val concurrency: Int,
val types: Set<Int>? = null, val types: Set<Int>? = null,
) )
@@ -103,7 +104,7 @@ class SearchBooksUseCase(
throw NoStackTraceException("可搜索书源为空") throw NoStackTraceException("可搜索书源为空")
} }
val merger = SearchResultMerger(keyword, request.precision) val merger = SearchResultMerger(keyword, request.matchMode)
val concurrency = request.concurrency.coerceAtLeast(1) val concurrency = request.concurrency.coerceAtLeast(1)
var hasMore = false var hasMore = false
var processedSources = 0 var processedSources = 0
@@ -116,7 +117,7 @@ class SearchBooksUseCase(
.flatMapMerge(concurrency) { searchableSource -> .flatMapMerge(concurrency) { searchableSource ->
flow { flow {
control.awaitResumed() control.awaitResumed()
emit(searchSource(searchableSource, keyword, request.page, request.precision)) emit(searchSource(searchableSource, keyword, request.page, request.matchMode))
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }
.collect { result -> .collect { result ->
@@ -177,7 +178,7 @@ class SearchBooksUseCase(
searchableSource: SearchableSource, searchableSource: SearchableSource,
keyword: String, keyword: String,
page: Int, page: Int,
precision: Boolean, matchMode: MatchMode,
): SourceSearchResult { ): SourceSearchResult {
return try { return try {
val source = searchableSource.source val source = searchableSource.source
@@ -191,7 +192,7 @@ class SearchBooksUseCase(
keyword, keyword,
page, page,
filter = { name, author -> filter = { name, author ->
!precision || matchMode == MatchMode.DEFAULT ||
name.contains(keyword, ignoreCase = true) || name.contains(keyword, ignoreCase = true) ||
author.contains(keyword, ignoreCase = true) author.contains(keyword, ignoreCase = true)
} }
@@ -222,7 +223,7 @@ class SearchBooksUseCase(
private class SearchResultMerger( private class SearchResultMerger(
private val keyword: String, private val keyword: String,
private val precision: Boolean, private val matchMode: MatchMode,
) { ) {
private companion object { private companion object {
const val MAX_RETAINED_SEARCH_RESULTS = 1000 const val MAX_RETAINED_SEARCH_RESULTS = 1000
@@ -267,9 +268,13 @@ class SearchBooksUseCase(
book.name.equals(keyword, ignoreCase = true) || book.name.equals(keyword, ignoreCase = true) ||
book.author.equals(keyword, ignoreCase = true) -> equalBooks book.author.equals(keyword, ignoreCase = true) -> equalBooks
book.name.contains(keyword, ignoreCase = true) || book.name.contains(keyword, ignoreCase = true) ||
book.author.contains(keyword, ignoreCase = true) -> containsBooks book.author.contains(
!precision -> otherBooks keyword,
else -> null 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar 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.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.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -68,9 +67,9 @@ fun ScopeSelectSheet(
title = title, title = title,
endAction = onConfirm?.let { endAction = onConfirm?.let {
{ {
MediumIconButton( MediumPlainButton(
onClick = it, onClick = it,
imageVector = Icons.Default.Check icon = AppIcons.Settings
) )
} }
} }
@@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() {
}, },
onOpenSourceManage = { onOpenSourceManage = {
startActivity<BookSourceActivity>() 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.SearchBook
import io.legado.app.data.entities.SearchKeyword import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.MatchMode
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable @Stable
data class SearchResultItemUi( data class SearchResultItemUi(
@@ -17,17 +20,17 @@ data class SearchResultItemUi(
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
val committedQuery: String = "", val committedQuery: String = "",
val results: List<SearchResultItemUi> = emptyList(), val results: ImmutableList<SearchResultItemUi> = persistentListOf(),
val history: List<SearchKeyword> = emptyList(), val history: ImmutableList<SearchKeyword> = persistentListOf(),
val bookshelfHints: List<BookShelfItem> = emptyList(), val bookshelfHints: ImmutableList<BookShelfItem> = persistentListOf(),
val enabledGroups: List<String> = emptyList(), val enabledGroups: ImmutableList<String> = persistentListOf(),
val enabledSources: List<BookSourcePart> = emptyList(), val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
val scopeDisplay: String = "", val scopeDisplay: String = "",
val scopeDisplayNames: List<String> = emptyList(), val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
val selectedScopeSourceUrls: Set<String> = emptySet(), val selectedScopeSourceUrls: Set<String> = emptySet(),
val isAllScope: Boolean = true, val isAllScope: Boolean = true,
val isSourceScope: Boolean = false, val isSourceScope: Boolean = false,
val isPrecisionSearch: Boolean = false, val matchMode: MatchMode = MatchMode.DEFAULT,
val isSearching: Boolean = false, val isSearching: Boolean = false,
val isManualStop: Boolean = false, val isManualStop: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
@@ -35,17 +38,24 @@ data class SearchUiState(
val totalSources: Int = 0, val totalSources: Int = 0,
val selectedSourceTypes: Set<Int> = emptySet(), val selectedSourceTypes: Set<Int> = emptySet(),
val showScopeSheet: Boolean = false, val showScopeSheet: Boolean = false,
val showTypeSheet: Boolean = false, val showSettingsSheet: Boolean = false,
val showClearHistoryDialog: Boolean = false, val showClearHistoryDialog: Boolean = false,
val showSuggestions: Boolean = true, val showSuggestions: Boolean = true,
val emptyScopeAction: SearchEmptyScopeAction? = null, val emptyScopeAction: SearchEmptyScopeAction? = null,
val savedScrollIndex: Int = 0, val savedScrollIndex: Int = 0,
val savedScrollOffset: 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( data class SearchEmptyScopeAction(
val scopeDisplay: String, val scopeDisplay: String,
val wasPrecisionSearch: Boolean, val wasMatchMode: MatchMode,
) )
sealed interface SearchIntent { sealed interface SearchIntent {
@@ -60,17 +70,22 @@ sealed interface SearchIntent {
data class UseHistoryKeyword(val keyword: String) : SearchIntent data class UseHistoryKeyword(val keyword: String) : SearchIntent
data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
data class OpenBookshelfBook(val book: BookShelfItem) : 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 DeleteHistory(val item: SearchKeyword) : SearchIntent
data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
data object ConfirmClearHistory : SearchIntent data object ConfirmClearHistory : SearchIntent
data class SetScopeSheetVisible(val visible: Boolean) : 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 class ToggleSourceType(val type: Int) : SearchIntent
data object SelectAllScope : SearchIntent data object SelectAllScope : SearchIntent
data class ToggleScopeGroup(val groupName: String) : SearchIntent data class ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
data class RemoveScopeItem(val scopeName: String) : 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 ConfirmEmptyScopeAction : SearchIntent
data object DismissEmptyScopeAction : SearchIntent data object DismissEmptyScopeAction : SearchIntent
data object OpenSourceManage : 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.Book
import androidx.compose.material.icons.filled.Close 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.Layers
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop 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.SearchBook
import io.legado.app.data.entities.SearchKeyword import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.model.BookShelfState 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.bookCoverSharedElementKey
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.ui.theme.LegadoTheme 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.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold 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.SearchBar
import io.legado.app.ui.widget.components.alert.AppAlertDialog 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.SearchBookListItem
import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet 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.SmallPlainButton
import io.legado.app.ui.widget.components.button.SmallTextButton
import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcon 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.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet 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.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.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar 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.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior 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.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
@@ -100,6 +103,8 @@ fun SearchScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
val isSourceGroupedMode = searchLayoutMode == 1
var previewBook by remember { mutableStateOf<SearchBook?>(null) } var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) } var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -149,13 +154,15 @@ fun SearchScreen(
state.hasMore, state.hasMore,
state.isManualStop, state.isManualStop,
state.showSuggestions, state.showSuggestions,
isSourceGroupedMode,
) { ) {
if ( if (
shouldLoadMore && shouldLoadMore &&
!state.isSearching && !state.isSearching &&
state.hasMore && state.hasMore &&
!state.isManualStop && !state.isManualStop &&
!state.showSuggestions !state.showSuggestions &&
!isSourceGroupedMode
) { ) {
viewModel.onIntent(SearchIntent.LoadMore) viewModel.onIntent(SearchIntent.LoadMore)
} }
@@ -261,32 +268,15 @@ fun SearchScreen(
) )
}, },
actions = { actions = {
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
TopBarAnimatedActionButton( TopBarAnimatedActionButton(
checked = state.isPrecisionSearch, checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(),
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(),
onCheckedChange = { onCheckedChange = {
viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true)) viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
}, },
iconChecked = Icons.Default.Layers, iconChecked = AppIcons.Settings,
iconUnchecked = Icons.Default.Layers, iconUnchecked = AppIcons.Settings,
activeText = "搜素类型", activeText = stringResource(R.string.setting),
inactiveText = "搜素类型", inactiveText = stringResource(R.string.setting),
) )
TopBarAnimatedActionButton( TopBarAnimatedActionButton(
checked = !state.isAllScope, checked = !state.isAllScope,
@@ -296,7 +286,7 @@ fun SearchScreen(
iconChecked = AppIcons.Filter, iconChecked = AppIcons.Filter,
iconUnchecked = AppIcons.Filter, iconUnchecked = AppIcons.Filter,
activeText = stringResource(R.string.screen), activeText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen), inactiveText = stringResource(R.string.screen)
) )
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
@@ -315,12 +305,13 @@ fun SearchScreen(
placeholder = searchLabel, placeholder = searchLabel,
trailingIcon = { trailingIcon = {
if (queryInput.isNotEmpty()) { if (queryInput.isNotEmpty()) {
TopBarActionButton( SmallPlainButton(
modifier = Modifier.padding(horizontal = 8.dp),
onClick = { onClick = {
queryInput = "" queryInput = ""
viewModel.onIntent(SearchIntent.UpdateQuery("")) viewModel.onIntent(SearchIntent.UpdateQuery(""))
}, },
imageVector = AppIcons.Close, icon = AppIcons.Close,
contentDescription = stringResource(R.string.clear) contentDescription = stringResource(R.string.clear)
) )
} }
@@ -394,52 +385,124 @@ fun SearchScreen(
} }
if (state.results.isNotEmpty()) { if (state.results.isNotEmpty()) {
LazyColumn( val sourceGroupedResults = remember(state.results) {
modifier = Modifier.fillMaxSize(), state.results
state = listState, .groupBy { it.book.origin }
contentPadding = adaptiveContentPaddingOnlyVertical( .map { (origin, books) ->
top = 48.dp, SourceGroup(
bottom = 8.dp origin = origin,
), sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() }
verticalArrangement = Arrangement.spacedBy(6.dp) ?: origin,
) { items = books
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 { AnimatedContent(
SearchResultFooter( targetState = isSourceGroupedMode,
isSearching = state.isSearching, label = "SearchLayoutTransition",
hasMore = state.hasMore, modifier = Modifier.fillMaxSize(),
hasResult = true, ) { isSourceGrouped ->
committedQuery = state.committedQuery, if (isSourceGrouped) {
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, 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), title = stringResource(R.string.draw),
textProvider = { textProvider = {
if (wasPrecisionSearch) { if (wasMatchMode == MatchMode.EXACT) {
stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay) stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
} else { } else {
stringResource(R.string.search_empty_scope_switch_all, scopeDisplay) stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
@@ -525,46 +588,101 @@ fun SearchScreen(
sources = state.enabledSources, sources = state.enabledSources,
selectedSources = state.selectedScopeSourceUrls, selectedSources = state.selectedScopeSourceUrls,
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) }, onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
) )
AppModalBottomSheet( AppModalBottomSheet(
show = state.showTypeSheet, show = state.showSettingsSheet,
onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) }, onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
title = "搜素类型", title = stringResource(R.string.setting),
) { ) {
Column { Column(
SelectionItemCard( verticalArrangement = Arrangement.spacedBy(8.dp)
title = stringResource(R.string.all), ) {
isSelected = state.selectedSourceTypes.isEmpty(), CompactDropdownSettingItem(
containerColor = LegadoTheme.colorScheme.onSheetContent, title = stringResource(R.string.layout_mode),
inSelectionMode = true, selectedValue = searchLayoutMode.toString(),
onToggleSelection = { displayEntries = arrayOf(
if (state.selectedSourceTypes.isNotEmpty()) { stringResource(R.string.search_layout_source_grouped),
state.selectedSourceTypes.forEach { stringResource(R.string.search_layout_list)
viewModel.onIntent(SearchIntent.ToggleSourceType(it)) ),
} 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( Row(
0 to stringResource(R.string.noval), modifier = Modifier
2 to stringResource(R.string.manga), .fillMaxWidth()
1 to stringResource(R.string.audio), .padding(vertical = 12.dp),
).forEach { (type, label) -> 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( SelectionItemCard(
title = label, title = stringResource(R.string.all),
isSelected = state.selectedSourceTypes.contains(type), isSelected = state.selectedSourceTypes.isEmpty(),
containerColor = LegadoTheme.colorScheme.onSheetContent, containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { 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)) Spacer(modifier = Modifier.height(20.dp))
@@ -588,6 +706,24 @@ fun SearchScreen(
viewModel.onAddToShelf(book) 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( private data class SearchFloatingSummary(
@@ -653,10 +789,10 @@ private fun SearchSuggestionPanel(
} }
if (state.history.isNotEmpty()) { if (state.history.isNotEmpty()) {
SmallTextButton( SmallPlainButton(
onClick = onClearHistory, onClick = onClearHistory,
text = stringResource(R.string.clear_all), text = stringResource(R.string.clear_all),
imageVector = Icons.Default.Close icon = Icons.Default.Close
) )
} }
} }
@@ -685,9 +821,9 @@ private fun SearchSuggestionPanel(
title = history.word, title = history.word,
onToggleSelection = { onUseHistory(history.word) }, onToggleSelection = { onUseHistory(history.word) },
trailingAction = { trailingAction = {
SmallIconButton( SmallPlainButton(
onClick = { onDeleteHistory(history) }, onClick = { onDeleteHistory(history) },
imageVector = Icons.Default.Close, icon = Icons.Default.Close,
contentDescription = stringResource(R.string.delete) 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook 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.data.repository.SearchRepository
import io.legado.app.domain.model.BookSearchScope 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.AddToBookshelfUseCase
import io.legado.app.domain.usecase.BookSearchControl import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.BookShelfKey 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.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.getPrefBoolean import kotlinx.collections.immutable.persistentListOf
import io.legado.app.utils.putPrefBoolean import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import splitties.init.appCtx
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel( class SearchViewModel(
private val repository: SearchRepository, private val repository: SearchRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val searchBooksUseCase: SearchBooksUseCase, private val searchBooksUseCase: SearchBooksUseCase,
private val exploreBooksUseCase: ExploreBooksUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase, private val addToBookshelfUseCase: AddToBookshelfUseCase,
private val localPreferencesRepository: LocalPreferencesRepository,
) : ViewModel() { ) : 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( private val _uiState = MutableStateFlow(
SearchUiState( SearchUiState(
isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
scopeDisplay = SearchScope(AppConfig.searchScope).display, scopeDisplay = SearchScope(AppConfig.searchScope).display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames, scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
isAllScope = SearchScope(AppConfig.searchScope).isAll(), isAllScope = SearchScope(AppConfig.searchScope).isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(), isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
) )
@@ -72,6 +95,7 @@ class SearchViewModel(
observeBookshelf() observeBookshelf()
observeQueryHistory() observeQueryHistory()
observeQueryBookshelfHints() observeQueryBookshelfHints()
observeMatchMode()
} }
fun onAddToShelf(book: SearchBook) { fun onAddToShelf(book: SearchBook) {
@@ -153,8 +177,8 @@ class SearchViewModel(
_uiState.update { it.copy(showScopeSheet = intent.visible) } _uiState.update { it.copy(showScopeSheet = intent.visible) }
} }
is SearchIntent.SetTypeSheetVisible -> { is SearchIntent.SetSettingsSheetVisible -> {
_uiState.update { it.copy(showTypeSheet = intent.visible) } _uiState.update { it.copy(showSettingsSheet = intent.visible) }
} }
is SearchIntent.ToggleSourceType -> { is SearchIntent.ToggleSourceType -> {
@@ -184,9 +208,12 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
is SearchIntent.TogglePrecision -> { is SearchIntent.SetMatchMode -> {
appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled) viewModelScope.launch {
_uiState.update { it.copy(isPrecisionSearch = intent.enabled) } localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
)
}
restartCommittedSearchIfNeeded() restartCommittedSearchIfNeeded()
} }
@@ -197,6 +224,61 @@ class SearchViewModel(
SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage) 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 -> { is SearchIntent.SaveScrollState -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -233,7 +315,7 @@ class SearchViewModel(
repository.enabledGroups repository.enabledGroups
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.collect { groups -> .collect { groups ->
_uiState.update { it.copy(enabledGroups = groups) } _uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
} }
} }
} }
@@ -243,7 +325,7 @@ class SearchViewModel(
repository.enabledSources repository.enabledSources
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.collect { sources -> .collect { sources ->
_uiState.update { it.copy(enabledSources = sources) } _uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
} }
} }
} }
@@ -255,7 +337,7 @@ class SearchViewModel(
.collect { keys -> .collect { keys ->
bookshelfKeys.value = keys bookshelfKeys.value = keys
_uiState.update { state -> _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) } .flatMapLatest { repository.searchHistory(it) }
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.collect { history -> .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) } .flatMapLatest { repository.searchBookshelf(it) }
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.collect { books -> .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) { private fun updateQuery(query: String, showSuggestions: Boolean) {
val currentState = _uiState.value val currentState = _uiState.value
val isSameQuery = currentState.query == query val isSameQuery = currentState.query == query
@@ -319,7 +409,7 @@ class SearchViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
committedQuery = keyword, committedQuery = keyword,
results = emptyList(), results = persistentListOf(),
isManualStop = false, isManualStop = false,
hasMore = true, hasMore = true,
processedSources = 0, processedSources = 0,
@@ -362,7 +452,7 @@ class SearchViewModel(
keyword = keyword, keyword = keyword,
page = page, page = page,
scope = BookSearchScope(searchScope.toString()), scope = BookSearchScope(searchScope.toString()),
precision = _uiState.value.isPrecisionSearch, matchMode = _uiState.value.matchMode,
concurrency = OtherConfig.threadCount, concurrency = OtherConfig.threadCount,
types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() }, types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
), ),
@@ -395,7 +485,7 @@ class SearchViewModel(
it.copy( it.copy(
results = buildSearchResultItems( results = buildSearchResultItems(
shelf = bookshelfKeys.value, shelf = bookshelfKeys.value,
), ).toImmutableList(),
processedSources = event.processedSources, processedSources = event.processedSources,
totalSources = event.totalSources, totalSources = event.totalSources,
) )
@@ -407,7 +497,7 @@ class SearchViewModel(
val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) { val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction( SearchEmptyScopeAction(
scopeDisplay = searchScope.display, scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch, wasMatchMode = state.matchMode,
) )
} else { } else {
null null
@@ -441,7 +531,7 @@ class SearchViewModel(
it.copy( it.copy(
query = "", query = "",
committedQuery = "", committedQuery = "",
results = emptyList(), results = persistentListOf(),
processedSources = 0, processedSources = 0,
totalSources = 0, totalSources = 0,
isSearching = false, isSearching = false,
@@ -497,9 +587,12 @@ class SearchViewModel(
val action = _uiState.value.emptyScopeAction ?: return val action = _uiState.value.emptyScopeAction ?: return
_uiState.update { it.copy(emptyScopeAction = null) } _uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasPrecisionSearch) { if (action.wasMatchMode == MatchMode.EXACT) {
appCtx.putPrefBoolean(PreferKey.precisionSearch, false) viewModelScope.launch {
_uiState.update { it.copy(isPrecisionSearch = false) } localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
)
}
} else { } else {
searchScope.update("") searchScope.update("")
syncScopeState() syncScopeState()
@@ -523,7 +616,7 @@ class SearchViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
scopeDisplay = searchScope.display, scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames, scopeDisplayNames = searchScope.displayNames.toImmutableList(),
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(), selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
isAllScope = searchScope.isAll(), isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(), 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) { private fun emitEffect(effect: SearchEffect) {
_effects.tryEmit(effect) _effects.tryEmit(effect)
} }
@@ -34,11 +34,9 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import io.legado.app.ui.widget.components.AppFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable 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.data.entities.SearchContentHistory
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding 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.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBar 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.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton import io.legado.app.ui.widget.components.button.SmallPlainButton
import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallToggleButton
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton import io.legado.app.ui.widget.components.button.ToggleStyle
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard 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.icon.AppIcon
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn 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.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar 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.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -302,14 +303,14 @@ fun SearchHistoryList(
color = LegadoTheme.colorScheme.primary, color = LegadoTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
SmallAnimatedActionButton( SmallToggleButton(
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
checked = onlyThisBook, checked = onlyThisBook,
onCheckedChange = { onToggleScope() }, onCheckedChange = { onToggleScope() },
style = ToggleStyle.Tonal,
iconChecked = Icons.Default.Book, iconChecked = Icons.Default.Book,
iconUnchecked = Icons.Default.CollectionsBookmark, icon = Icons.Default.CollectionsBookmark,
activeText = "仅本书", text = "仅本书"
inactiveText = "所有记录"
) )
} }
@@ -338,9 +339,9 @@ fun SearchHistoryList(
Icon(Icons.Default.History, contentDescription = null) Icon(Icons.Default.History, contentDescription = null)
}, },
trailingContent = { trailingContent = {
SmallIconButton( SmallPlainButton(
onClick = { onDeleteHistory(item) }, onClick = { onDeleteHistory(item) },
imageVector = Icons.Default.Close, icon = Icons.Default.Close,
contentDescription = "删除" contentDescription = "删除"
) )
}, },
@@ -361,7 +362,7 @@ fun SearchHistoryList(
MediumOutlinedButton( MediumOutlinedButton(
onClick = onClearHistory, onClick = onClearHistory,
modifier = Modifier.fillMaxWidth(0.6f), modifier = Modifier.fillMaxWidth(0.6f),
imageVector = Icons.Outlined.DeleteSweep, icon = Icons.Outlined.DeleteSweep,
text = "清除搜索历史" text = "清除搜索历史"
) )
} }