From 7f8e48fa83ea220a2628b23c507fd071393d86c8 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:18:55 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/data/repository/SearchRepository.kt | 177 +++++ .../io/legado/app/ui/book/search/BookKey.kt | 7 + .../app/ui/book/search/SearchContract.kt | 73 ++ .../legado/app/ui/book/search/SearchScreen.kt | 668 ++++++++++++++++++ .../widget/components/book/SearchBookItem.kt | 219 ++++++ 5 files changed, 1144 insertions(+) create mode 100644 app/src/main/java/io/legado/app/data/repository/SearchRepository.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/search/BookKey.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt diff --git a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt new file mode 100644 index 000000000..a84b56771 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt @@ -0,0 +1,177 @@ +package io.legado.app.data.repository + +import io.legado.app.data.AppDatabase +import io.legado.app.data.entities.BookSourcePart +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.help.book.isNotShelf +import io.legado.app.model.webBook.SearchModel +import io.legado.app.ui.book.search.BookKey +import io.legado.app.ui.book.search.SearchScope +import io.legado.app.ui.main.bookshelf.BookShelfItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface SearchRepository { + val enabledGroups: Flow> + val enabledSources: Flow> + val bookshelfKeys: Flow> + + fun searchBookshelf(query: String): Flow> + fun searchHistory(query: String): Flow> + + 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, + 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 + 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> = appDb.bookSourceDao.flowEnabledGroups() + override val enabledSources: Flow> = appDb.bookSourceDao.flowEnabled() + + override val bookshelfKeys: Flow> = 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> { + val keyword = query.trim() + return if (keyword.isBlank()) { + flowOf(emptyList()) + } else { + appDb.bookDao.flowBookShelfSearch(keyword) + } + } + + override fun searchHistory(query: String): Flow> { + 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(extraBufferCapacity = 64) + override val events: Flow = _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, + 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() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/search/BookKey.kt b/app/src/main/java/io/legado/app/ui/book/search/BookKey.kt new file mode 100644 index 000000000..bb271c54b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/BookKey.kt @@ -0,0 +1,7 @@ +package io.legado.app.ui.book.search + +data class BookKey( + val name: String, + val author: String, + val url: String? +) diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt new file mode 100644 index 000000000..08ef305df --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt @@ -0,0 +1,73 @@ +package io.legado.app.ui.book.search + +import io.legado.app.data.entities.BookSourcePart +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.ui.main.bookshelf.BookShelfItem + +data class SearchUiState( + val query: String = "", + val committedQuery: String = "", + val results: List = emptyList(), + val history: List = emptyList(), + val bookshelfHints: List = emptyList(), + val enabledGroups: List = emptyList(), + val enabledSources: List = emptyList(), + val scopeDisplay: String = "", + val scopeDisplayNames: List = emptyList(), + val selectedScopeSourceUrls: Set = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt new file mode 100644 index 000000000..26b5d7366 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -0,0 +1,668 @@ +package io.legado.app.ui.book.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.ui.main.bookshelf.BookShelfItem +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.AppFloatingActionButton +import io.legado.app.ui.widget.components.AppSearchBar +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.book.SearchBookListItem +import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.button.SmallTextButton +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.button.ToggleChip +import io.legado.app.ui.widget.components.button.TopBarActionButton +import io.legado.app.ui.widget.components.button.TopBarAnimatedActionButton +import io.legado.app.ui.widget.components.button.TopBarNavigationButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.icon.AppIcons +import io.legado.app.ui.widget.components.tabRow.AppTabRow +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + viewModel: SearchViewModel, + onBack: () -> Unit, + onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit, + onOpenSourceManage: () -> Unit, +) { + val context = LocalContext.current + val state by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + var queryInput by rememberSaveable { mutableStateOf(state.query) } + var searchExpanded by rememberSaveable { mutableStateOf(false) } + var scopeSheetTab by rememberSaveable { mutableStateOf(0) } + val latestQuery by rememberUpdatedState(state.query) + val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { + GlassTopAppBarDefaults.defaultScrollBehavior() + } else { + M3GlassScrollBehavior(TopAppBarDefaults.enterAlwaysScrollBehavior()) + } + + val shouldLoadMore by remember { + derivedStateOf { + val totalCount = listState.layoutInfo.totalItemsCount + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + totalCount > 0 && lastVisible >= totalCount - 3 + } + } + + LaunchedEffect(state.query) { + if (state.query != queryInput) { + queryInput = state.query + } + } + + LaunchedEffect(state.showScopeSheet, state.isSourceScope) { + if (state.showScopeSheet) { + scopeSheetTab = if (state.isSourceScope) 1 else 0 + } + } + + LaunchedEffect(Unit) { + snapshotFlow { queryInput } + .distinctUntilChanged() + .debounce(200) + .filter { it != latestQuery } + .collect { newQuery -> + viewModel.onIntent(SearchIntent.UpdateQuery(newQuery)) + } + } + + LaunchedEffect( + shouldLoadMore, + state.isSearching, + state.hasMore, + state.isManualStop, + state.showSuggestions, + searchExpanded, + ) { + if ( + shouldLoadMore && + !state.isSearching && + state.hasMore && + !state.isManualStop && + !state.showSuggestions && + !searchExpanded + ) { + viewModel.onIntent(SearchIntent.LoadMore) + } + } + + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is SearchEffect.OpenBookInfo -> { + onOpenBookInfo(effect.name, effect.author, effect.bookUrl) + } + + SearchEffect.OpenSourceManage -> onOpenSourceManage() + is SearchEffect.ShowMessage -> context.toastOnUi(effect.message) + } + } + } + + val submitSearch: (String) -> Unit = { rawQuery -> + val normalized = rawQuery.trim() + if (normalized.isNotBlank()) { + queryInput = normalized + if (normalized != state.query) { + viewModel.onIntent(SearchIntent.UpdateQuery(normalized)) + } + viewModel.onIntent(SearchIntent.SubmitSearch) + } + } + val searchLabel = stringResource(R.string.search_book_key) + val showResultCountCard = state.committedQuery.isNotBlank() || state.isSearching + val showSourceProgressCard = state.totalSources > 0 + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + Column { + GlassMediumFlexibleTopAppBar( + title = stringResource(R.string.search), + navigationIcon = { + TopBarNavigationButton( + onClick = onBack, + imageVector = AppIcons.Back + ) + }, + actions = { + if (!searchExpanded) { + TopBarActionButton( + onClick = { + viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true)) + }, + imageVector = AppIcons.Filter + ) + TopBarActionButton( + onClick = { + viewModel.onIntent(SearchIntent.OpenSourceManage) + }, + imageVector = AppIcons.Settings, + ) + } + }, + scrollBehavior = scrollBehavior + ) + + Spacer(modifier = Modifier.height(8.dp)) + AppSearchBar( + modifier = Modifier.fillMaxWidth(), + query = queryInput, + onQueryChange = { queryInput = it }, + onSearch = submitSearch, + expanded = searchExpanded, + onExpandedChange = { searchExpanded = it }, + label = searchLabel, + placeholder = { Text(text = searchLabel) }, + leadingIcon = { + TopBarActionButton( + onClick = { + if (searchExpanded) { + searchExpanded = false + } + }, + imageVector = if (searchExpanded) AppIcons.Back else AppIcons.Search, + contentDescription = if (searchExpanded) stringResource(R.string.search) else stringResource( + R.string.search + ) + ) + }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (queryInput.isNotEmpty()) { + TopBarActionButton( + onClick = { + queryInput = "" + viewModel.onIntent(SearchIntent.UpdateQuery("")) + }, + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.clear) + ) + } + TopBarAnimatedActionButton( + checked = state.isPrecisionSearch, + onCheckedChange = { checked -> + viewModel.onIntent(SearchIntent.TogglePrecision(checked)) + }, + iconChecked = AppIcons.PrecisionSearch, + iconUnchecked = AppIcons.UnPrecisionSearch, + activeText = stringResource(R.string.precision_search), + inactiveText = stringResource(R.string.search), + ) + if (searchExpanded) { + TopBarActionButton( + onClick = { + viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true)) + }, + imageVector = AppIcons.Filter, + contentDescription = stringResource(R.string.search_select_group) + ) + TopBarActionButton( + onClick = { + viewModel.onIntent(SearchIntent.OpenSourceManage) + }, + imageVector = AppIcons.Settings, + contentDescription = stringResource(R.string.book_source_manage) + ) + } + } + }, + ) { + SearchSuggestionPanel( + state = state, + onUseHistory = { keyword -> + queryInput = keyword + viewModel.onIntent(SearchIntent.UseHistoryKeyword(keyword)) + searchExpanded = false + }, + onDeleteHistory = { viewModel.onIntent(SearchIntent.DeleteHistory(it)) }, + onOpenBook = { + viewModel.onIntent(SearchIntent.OpenBookshelfBook(it)) + searchExpanded = false + }, + onClearHistory = { + viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(true)) + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + }, + floatingActionButton = { + val showFab = state.isSearching || (state.committedQuery.isNotBlank() && state.hasMore) + if (showFab) { + AppFloatingActionButton( + onClick = { + if (state.isSearching) { + viewModel.onIntent(SearchIntent.StopSearch) + } else { + viewModel.onIntent(SearchIntent.LoadMore) + } + } + ) { + AppIcon( + imageVector = if (state.isSearching) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = if (state.isSearching) stringResource(R.string.stop) else stringResource(R.string.start), + ) + } + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (showResultCountCard || showSourceProgressCard) { + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 36.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (showResultCountCard) { + TextCard( + modifier = Modifier.weight(1f), + icon = AppIcons.Search, + text = "搜索结果 ${state.results.size}" + ) + } + if (showSourceProgressCard) { + TextCard( + modifier = Modifier.weight(1f), + icon = AppIcons.Filter, + text = "已检索源 ${state.processedSources}/${state.totalSources}" + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (state.results.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SearchResultFooter( + isSearching = state.isSearching, + hasMore = state.hasMore, + hasResult = false, + committedQuery = state.committedQuery, + onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(items = state.results, key = { it.bookUrl }) { book -> + val shelfStateFlow = remember(book.bookUrl) { + viewModel.getBookShelfStateFlow(book) + } + SearchBookListItem( + book = book, + shelfState = shelfStateFlow, + onClick = { + viewModel.onIntent(SearchIntent.OpenSearchBook(book)) + } + ) + } + + item { + SearchResultFooter( + isSearching = state.isSearching, + hasMore = state.hasMore, + hasResult = true, + committedQuery = state.committedQuery, + onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, + ) + } + } + } + } + } + + AppAlertDialog( + show = state.showClearHistoryDialog, + onDismissRequest = { + viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false)) + }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_clear_search_history), + confirmText = stringResource(R.string.ok), + onConfirm = { viewModel.onIntent(SearchIntent.ConfirmClearHistory) }, + dismissText = stringResource(R.string.cancel), + onDismiss = { + viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false)) + }, + ) + + AppAlertDialog( + data = state.emptyScopeAction, + onDismissRequest = { + viewModel.onIntent(SearchIntent.DismissEmptyScopeAction) + }, + title = stringResource(R.string.draw), + confirmText = stringResource(R.string.ok), + onConfirm = { viewModel.onIntent(SearchIntent.ConfirmEmptyScopeAction) }, + dismissText = stringResource(R.string.cancel), + onDismiss = { viewModel.onIntent(SearchIntent.DismissEmptyScopeAction) }, + content = { + Text( + text = if (it.wasPrecisionSearch) { + "${it.scopeDisplay}分组搜索结果为空,是否关闭精准搜索?" + } else { + "${it.scopeDisplay}分组搜索结果为空,是否切换到全部分组?" + } + ) + } + ) + + AppModalBottomSheet( + show = state.showScopeSheet, + onDismissRequest = { viewModel.onIntent(SearchIntent.SetScopeSheetVisible(false)) }, + title = stringResource(R.string.search_select_group), + ) { + Column { + SelectionItemCard( + title = stringResource(R.string.all_source), + isSelected = state.isAllScope, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + inSelectionMode = true, + onToggleSelection = { + viewModel.onIntent(SearchIntent.SelectAllScope) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + AppTabRow( + tabTitles = listOf( + stringResource(R.string.group), + stringResource(R.string.book_source), + ), + selectedTabIndex = scopeSheetTab, + onTabSelected = { scopeSheetTab = it }, + ) + Spacer(modifier = Modifier.height(6.dp)) + + if (scopeSheetTab == 0) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.enabledGroups, key = { it }) { + val selected = !state.isSourceScope && state.scopeDisplayNames.contains(it) + SelectionItemCard( + title = it, + isSelected = selected, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + inSelectionMode = true, + onToggleSelection = { + viewModel.onIntent(SearchIntent.ToggleScopeGroup(it)) + } + ) + } + } + } else { + if (state.enabledSources.isEmpty()) { + Text( + text = stringResource(R.string.search_empty), + style = LegadoTheme.typography.bodyMedium, + color = LegadoTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.enabledSources, key = { it.bookSourceUrl }) { + val selected = state.selectedScopeSourceUrls.contains(it.bookSourceUrl) + SelectionItemCard( + title = it.bookSourceName, + subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() }, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + isSelected = selected, + inSelectionMode = true, + onToggleSelection = { + viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + } + } +} + +@Composable +private fun SearchSuggestionPanel( + state: SearchUiState, + onUseHistory: (String) -> Unit, + onDeleteHistory: (SearchKeyword) -> Unit, + onOpenBook: (BookShelfItem) -> Unit, + onClearHistory: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = adaptiveContentPadding( + top = 8.dp, + bottom = 8.dp + ) + ) { + if (state.bookshelfHints.isNotEmpty()) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(Icons.Default.Book, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + AppText( + text = stringResource(R.string.bookshelf), + style = LegadoTheme.typography.titleSmall, + ) + } + } + + items(state.bookshelfHints, key = { it.bookUrl }) { book -> + SelectionItemCard( + modifier = Modifier.animateItem(), + title = book.name, + subtitle = book.author, + onToggleSelection = { onOpenBook(book) } + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(AppIcons.History, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + AppText( + text = stringResource(R.string.searchHistory), + style = LegadoTheme.typography.titleSmall, + ) + } + + if (state.history.isNotEmpty()) { + SmallTextButton( + onClick = onClearHistory, + text = stringResource(R.string.clear_all), + imageVector = Icons.Default.Close + ) + } + } + } + + if (state.history.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.search_empty), + style = LegadoTheme.typography.bodyMedium, + color = LegadoTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } else { + items(state.history, key = { it.word }) { history -> + SelectionItemCard( + modifier = Modifier.animateItem(), + title = history.word, + onToggleSelection = { onUseHistory(history.word) }, + trailingAction = { + SmallIconButton( + onClick = { onDeleteHistory(history) }, + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.delete) + ) + } + ) + } + } + } +} + +@Composable +private fun SearchResultFooter( + isSearching: Boolean, + hasMore: Boolean, + hasResult: Boolean, + committedQuery: String, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + when { + isSearching -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.width(18.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + AppText(text = stringResource(R.string.is_loading)) + } + } + + !hasResult && committedQuery.isNotBlank() -> { + Text( + text = stringResource(R.string.search_empty), + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + + hasMore -> { + Text( + text = stringResource(R.string.search_empty), + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + Text( + text = stringResource(R.string.search_empty), + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt new file mode 100644 index 000000000..53456a097 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt @@ -0,0 +1,219 @@ +package io.legado.app.ui.widget.components.book + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.legado.app.data.entities.SearchBook +import io.legado.app.model.BookShelfState +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.cover.Cover +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.coroutines.flow.Flow + +@Composable +fun SearchBookListItem( + book: SearchBook, + shelfState: Flow, + 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, + 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, + ) + } +}