[优化] 重写搜索界面
This commit is contained in:
5 files changed
+1144
No files matched your search
@@ -0,0 +1,177 @@
|
|||||||
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
|
import io.legado.app.data.AppDatabase
|
||||||
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
|
import io.legado.app.data.entities.SearchBook
|
||||||
|
import io.legado.app.data.entities.SearchKeyword
|
||||||
|
import io.legado.app.help.book.isNotShelf
|
||||||
|
import io.legado.app.model.webBook.SearchModel
|
||||||
|
import io.legado.app.ui.book.search.BookKey
|
||||||
|
import io.legado.app.ui.book.search.SearchScope
|
||||||
|
import io.legado.app.ui.main.bookshelf.BookShelfItem
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
interface SearchRepository {
|
||||||
|
val enabledGroups: Flow<List<String>>
|
||||||
|
val enabledSources: Flow<List<BookSourcePart>>
|
||||||
|
val bookshelfKeys: Flow<Set<BookKey>>
|
||||||
|
|
||||||
|
fun searchBookshelf(query: String): Flow<List<BookShelfItem>>
|
||||||
|
fun searchHistory(query: String): Flow<List<SearchKeyword>>
|
||||||
|
|
||||||
|
suspend fun saveSearchKeyword(keyword: String)
|
||||||
|
suspend fun deleteSearchKeyword(item: SearchKeyword)
|
||||||
|
suspend fun clearSearchKeywords()
|
||||||
|
|
||||||
|
fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SearchSessionEvent {
|
||||||
|
data object Started : SearchSessionEvent
|
||||||
|
|
||||||
|
data class Progress(
|
||||||
|
val books: List<SearchBook>,
|
||||||
|
val processedSources: Int,
|
||||||
|
val totalSources: Int,
|
||||||
|
) : SearchSessionEvent
|
||||||
|
|
||||||
|
data class Finished(
|
||||||
|
val isEmpty: Boolean,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
) : SearchSessionEvent
|
||||||
|
|
||||||
|
data class Canceled(val throwable: Throwable? = null) : SearchSessionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSession {
|
||||||
|
val events: Flow<SearchSessionEvent>
|
||||||
|
fun search(searchId: Long, keyword: String)
|
||||||
|
fun stop()
|
||||||
|
fun pause()
|
||||||
|
fun resume()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchRepositoryImpl(
|
||||||
|
private val appDb: AppDatabase,
|
||||||
|
) : SearchRepository {
|
||||||
|
|
||||||
|
override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
|
||||||
|
override val enabledSources: Flow<List<BookSourcePart>> = appDb.bookSourceDao.flowEnabled()
|
||||||
|
|
||||||
|
override val bookshelfKeys: Flow<Set<BookKey>> = appDb.bookDao.flowBookShelf().map { books ->
|
||||||
|
books.filterNot { it.isNotShelf }
|
||||||
|
.map { book -> BookKey(book.name, book.author, book.bookUrl) }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchBookshelf(query: String): Flow<List<BookShelfItem>> {
|
||||||
|
val keyword = query.trim()
|
||||||
|
return if (keyword.isBlank()) {
|
||||||
|
flowOf(emptyList())
|
||||||
|
} else {
|
||||||
|
appDb.bookDao.flowBookShelfSearch(keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchHistory(query: String): Flow<List<SearchKeyword>> {
|
||||||
|
val keyword = query.trim()
|
||||||
|
return if (keyword.isBlank()) {
|
||||||
|
appDb.searchKeywordDao.flowByTime()
|
||||||
|
} else {
|
||||||
|
appDb.searchKeywordDao.flowSearch(keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveSearchKeyword(keyword: String) = withContext(Dispatchers.IO) {
|
||||||
|
val key = keyword.trim()
|
||||||
|
if (key.isBlank()) return@withContext
|
||||||
|
|
||||||
|
appDb.searchKeywordDao.get(key)?.let { history ->
|
||||||
|
history.usage += 1
|
||||||
|
history.lastUseTime = System.currentTimeMillis()
|
||||||
|
appDb.searchKeywordDao.update(history)
|
||||||
|
} ?: appDb.searchKeywordDao.insert(SearchKeyword(word = key, usage = 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteSearchKeyword(item: SearchKeyword) = withContext(Dispatchers.IO) {
|
||||||
|
appDb.searchKeywordDao.delete(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearSearchKeywords() = withContext(Dispatchers.IO) {
|
||||||
|
appDb.searchKeywordDao.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession {
|
||||||
|
return SearchSessionImpl(scopeProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SearchSessionImpl(
|
||||||
|
scopeProvider: () -> SearchScope,
|
||||||
|
) : SearchSession {
|
||||||
|
|
||||||
|
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 64)
|
||||||
|
override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
|
||||||
|
override fun getSearchScope(): SearchScope = scopeProvider()
|
||||||
|
|
||||||
|
override fun onSearchStart() {
|
||||||
|
_events.tryEmit(SearchSessionEvent.Started)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchSuccess(
|
||||||
|
searchBooks: List<SearchBook>,
|
||||||
|
processedSources: Int,
|
||||||
|
totalSources: Int,
|
||||||
|
) {
|
||||||
|
_events.tryEmit(
|
||||||
|
SearchSessionEvent.Progress(
|
||||||
|
books = searchBooks.toList(),
|
||||||
|
processedSources = processedSources,
|
||||||
|
totalSources = totalSources,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
|
||||||
|
_events.tryEmit(SearchSessionEvent.Finished(isEmpty, hasMore))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchCancel(exception: Throwable?) {
|
||||||
|
_events.tryEmit(SearchSessionEvent.Canceled(exception))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun search(searchId: Long, keyword: String) {
|
||||||
|
searchModel.search(searchId, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
searchModel.cancelSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
searchModel.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
searchModel.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
searchModel.close()
|
||||||
|
sessionScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package io.legado.app.ui.book.search
|
||||||
|
|
||||||
|
data class BookKey(
|
||||||
|
val name: String,
|
||||||
|
val author: String,
|
||||||
|
val url: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package io.legado.app.ui.book.search
|
||||||
|
|
||||||
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
|
import io.legado.app.data.entities.SearchBook
|
||||||
|
import io.legado.app.data.entities.SearchKeyword
|
||||||
|
import io.legado.app.ui.main.bookshelf.BookShelfItem
|
||||||
|
|
||||||
|
data class SearchUiState(
|
||||||
|
val query: String = "",
|
||||||
|
val committedQuery: String = "",
|
||||||
|
val results: List<SearchBook> = emptyList(),
|
||||||
|
val history: List<SearchKeyword> = emptyList(),
|
||||||
|
val bookshelfHints: List<BookShelfItem> = emptyList(),
|
||||||
|
val enabledGroups: List<String> = emptyList(),
|
||||||
|
val enabledSources: List<BookSourcePart> = emptyList(),
|
||||||
|
val scopeDisplay: String = "",
|
||||||
|
val scopeDisplayNames: List<String> = emptyList(),
|
||||||
|
val selectedScopeSourceUrls: Set<String> = emptySet(),
|
||||||
|
val isAllScope: Boolean = true,
|
||||||
|
val isSourceScope: Boolean = false,
|
||||||
|
val isPrecisionSearch: Boolean = false,
|
||||||
|
val isSearching: Boolean = false,
|
||||||
|
val isManualStop: Boolean = false,
|
||||||
|
val hasMore: Boolean = true,
|
||||||
|
val processedSources: Int = 0,
|
||||||
|
val totalSources: Int = 0,
|
||||||
|
val showScopeSheet: Boolean = false,
|
||||||
|
val showClearHistoryDialog: Boolean = false,
|
||||||
|
val showSuggestions: Boolean = true,
|
||||||
|
val emptyScopeAction: SearchEmptyScopeAction? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchEmptyScopeAction(
|
||||||
|
val scopeDisplay: String,
|
||||||
|
val wasPrecisionSearch: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SearchIntent {
|
||||||
|
data class Initialize(val key: String?, val scopeRaw: String?) : SearchIntent
|
||||||
|
data class UpdateQuery(val query: String) : SearchIntent
|
||||||
|
data object SubmitSearch : SearchIntent
|
||||||
|
data object LoadMore : SearchIntent
|
||||||
|
data object StopSearch : SearchIntent
|
||||||
|
data object PauseEngine : SearchIntent
|
||||||
|
data object ResumeEngine : SearchIntent
|
||||||
|
data class UseHistoryKeyword(val keyword: String) : SearchIntent
|
||||||
|
data class OpenSearchBook(val book: SearchBook) : SearchIntent
|
||||||
|
data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
|
||||||
|
data class DeleteHistory(val item: SearchKeyword) : SearchIntent
|
||||||
|
data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
|
||||||
|
data object ConfirmClearHistory : SearchIntent
|
||||||
|
data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
|
||||||
|
data object SelectAllScope : SearchIntent
|
||||||
|
data class ToggleScopeGroup(val groupName: String) : SearchIntent
|
||||||
|
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
|
||||||
|
data class RemoveScopeItem(val scopeName: String) : SearchIntent
|
||||||
|
data class TogglePrecision(val enabled: Boolean) : SearchIntent
|
||||||
|
data object ConfirmEmptyScopeAction : SearchIntent
|
||||||
|
data object DismissEmptyScopeAction : SearchIntent
|
||||||
|
data object OpenSourceManage : SearchIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SearchEffect {
|
||||||
|
data class OpenBookInfo(
|
||||||
|
val name: String,
|
||||||
|
val author: String,
|
||||||
|
val bookUrl: String,
|
||||||
|
) : SearchEffect
|
||||||
|
|
||||||
|
data object OpenSourceManage : SearchEffect
|
||||||
|
|
||||||
|
data class ShowMessage(val message: String) : SearchEffect
|
||||||
|
}
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
package io.legado.app.ui.book.search
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Book
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
||||||
|
import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
|
||||||
|
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
||||||
|
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.data.entities.SearchKeyword
|
||||||
|
import io.legado.app.ui.main.bookshelf.BookShelfItem
|
||||||
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.theme.ThemeResolver
|
||||||
|
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||||
|
import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
|
||||||
|
import io.legado.app.ui.theme.adaptiveHorizontalPadding
|
||||||
|
import io.legado.app.ui.widget.components.AppFloatingActionButton
|
||||||
|
import io.legado.app.ui.widget.components.AppSearchBar
|
||||||
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
|
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
|
import io.legado.app.ui.widget.components.book.SearchBookListItem
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallTextButton
|
||||||
|
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
||||||
|
import io.legado.app.ui.widget.components.card.TextCard
|
||||||
|
import io.legado.app.ui.widget.components.button.ToggleChip
|
||||||
|
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
||||||
|
import io.legado.app.ui.widget.components.button.TopBarAnimatedActionButton
|
||||||
|
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
|
||||||
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
import io.legado.app.ui.widget.components.icon.AppIcon
|
||||||
|
import io.legado.app.ui.widget.components.icon.AppIcons
|
||||||
|
import io.legado.app.ui.widget.components.tabRow.AppTabRow
|
||||||
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
viewModel: SearchViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
|
||||||
|
onOpenSourceManage: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var queryInput by rememberSaveable { mutableStateOf(state.query) }
|
||||||
|
var searchExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
|
||||||
|
val latestQuery by rememberUpdatedState(state.query)
|
||||||
|
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
|
||||||
|
GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
|
} else {
|
||||||
|
M3GlassScrollBehavior(TopAppBarDefaults.enterAlwaysScrollBehavior())
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val totalCount = listState.layoutInfo.totalItemsCount
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
totalCount > 0 && lastVisible >= totalCount - 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.query) {
|
||||||
|
if (state.query != queryInput) {
|
||||||
|
queryInput = state.query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.showScopeSheet, state.isSourceScope) {
|
||||||
|
if (state.showScopeSheet) {
|
||||||
|
scopeSheetTab = if (state.isSourceScope) 1 else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { queryInput }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(200)
|
||||||
|
.filter { it != latestQuery }
|
||||||
|
.collect { newQuery ->
|
||||||
|
viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
shouldLoadMore,
|
||||||
|
state.isSearching,
|
||||||
|
state.hasMore,
|
||||||
|
state.isManualStop,
|
||||||
|
state.showSuggestions,
|
||||||
|
searchExpanded,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
shouldLoadMore &&
|
||||||
|
!state.isSearching &&
|
||||||
|
state.hasMore &&
|
||||||
|
!state.isManualStop &&
|
||||||
|
!state.showSuggestions &&
|
||||||
|
!searchExpanded
|
||||||
|
) {
|
||||||
|
viewModel.onIntent(SearchIntent.LoadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is SearchEffect.OpenBookInfo -> {
|
||||||
|
onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchEffect.OpenSourceManage -> onOpenSourceManage()
|
||||||
|
is SearchEffect.ShowMessage -> context.toastOnUi(effect.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val submitSearch: (String) -> Unit = { rawQuery ->
|
||||||
|
val normalized = rawQuery.trim()
|
||||||
|
if (normalized.isNotBlank()) {
|
||||||
|
queryInput = normalized
|
||||||
|
if (normalized != state.query) {
|
||||||
|
viewModel.onIntent(SearchIntent.UpdateQuery(normalized))
|
||||||
|
}
|
||||||
|
viewModel.onIntent(SearchIntent.SubmitSearch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val searchLabel = stringResource(R.string.search_book_key)
|
||||||
|
val showResultCountCard = state.committedQuery.isNotBlank() || state.isSearching
|
||||||
|
val showSourceProgressCard = state.totalSources > 0
|
||||||
|
|
||||||
|
AppScaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
GlassMediumFlexibleTopAppBar(
|
||||||
|
title = stringResource(R.string.search),
|
||||||
|
navigationIcon = {
|
||||||
|
TopBarNavigationButton(
|
||||||
|
onClick = onBack,
|
||||||
|
imageVector = AppIcons.Back
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!searchExpanded) {
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true))
|
||||||
|
},
|
||||||
|
imageVector = AppIcons.Filter
|
||||||
|
)
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onIntent(SearchIntent.OpenSourceManage)
|
||||||
|
},
|
||||||
|
imageVector = AppIcons.Settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
AppSearchBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
query = queryInput,
|
||||||
|
onQueryChange = { queryInput = it },
|
||||||
|
onSearch = submitSearch,
|
||||||
|
expanded = searchExpanded,
|
||||||
|
onExpandedChange = { searchExpanded = it },
|
||||||
|
label = searchLabel,
|
||||||
|
placeholder = { Text(text = searchLabel) },
|
||||||
|
leadingIcon = {
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (searchExpanded) {
|
||||||
|
searchExpanded = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imageVector = if (searchExpanded) AppIcons.Back else AppIcons.Search,
|
||||||
|
contentDescription = if (searchExpanded) stringResource(R.string.search) else stringResource(
|
||||||
|
R.string.search
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (queryInput.isNotEmpty()) {
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
queryInput = ""
|
||||||
|
viewModel.onIntent(SearchIntent.UpdateQuery(""))
|
||||||
|
},
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TopBarAnimatedActionButton(
|
||||||
|
checked = state.isPrecisionSearch,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
viewModel.onIntent(SearchIntent.TogglePrecision(checked))
|
||||||
|
},
|
||||||
|
iconChecked = AppIcons.PrecisionSearch,
|
||||||
|
iconUnchecked = AppIcons.UnPrecisionSearch,
|
||||||
|
activeText = stringResource(R.string.precision_search),
|
||||||
|
inactiveText = stringResource(R.string.search),
|
||||||
|
)
|
||||||
|
if (searchExpanded) {
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true))
|
||||||
|
},
|
||||||
|
imageVector = AppIcons.Filter,
|
||||||
|
contentDescription = stringResource(R.string.search_select_group)
|
||||||
|
)
|
||||||
|
TopBarActionButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onIntent(SearchIntent.OpenSourceManage)
|
||||||
|
},
|
||||||
|
imageVector = AppIcons.Settings,
|
||||||
|
contentDescription = stringResource(R.string.book_source_manage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
SearchSuggestionPanel(
|
||||||
|
state = state,
|
||||||
|
onUseHistory = { keyword ->
|
||||||
|
queryInput = keyword
|
||||||
|
viewModel.onIntent(SearchIntent.UseHistoryKeyword(keyword))
|
||||||
|
searchExpanded = false
|
||||||
|
},
|
||||||
|
onDeleteHistory = { viewModel.onIntent(SearchIntent.DeleteHistory(it)) },
|
||||||
|
onOpenBook = {
|
||||||
|
viewModel.onIntent(SearchIntent.OpenBookshelfBook(it))
|
||||||
|
searchExpanded = false
|
||||||
|
},
|
||||||
|
onClearHistory = {
|
||||||
|
viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(true))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
val showFab = state.isSearching || (state.committedQuery.isNotBlank() && state.hasMore)
|
||||||
|
if (showFab) {
|
||||||
|
AppFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (state.isSearching) {
|
||||||
|
viewModel.onIntent(SearchIntent.StopSearch)
|
||||||
|
} else {
|
||||||
|
viewModel.onIntent(SearchIntent.LoadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AppIcon(
|
||||||
|
imageVector = if (state.isSearching) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = if (state.isSearching) stringResource(R.string.stop) else stringResource(R.string.start),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
if (showResultCountCard || showSourceProgressCard) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 36.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (showResultCountCard) {
|
||||||
|
TextCard(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = AppIcons.Search,
|
||||||
|
text = "搜索结果 ${state.results.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showSourceProgressCard) {
|
||||||
|
TextCard(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = AppIcons.Filter,
|
||||||
|
text = "已检索源 ${state.processedSources}/${state.totalSources}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (state.results.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
SearchResultFooter(
|
||||||
|
isSearching = state.isSearching,
|
||||||
|
hasMore = state.hasMore,
|
||||||
|
hasResult = false,
|
||||||
|
committedQuery = state.committedQuery,
|
||||||
|
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
items(items = state.results, key = { it.bookUrl }) { book ->
|
||||||
|
val shelfStateFlow = remember(book.bookUrl) {
|
||||||
|
viewModel.getBookShelfStateFlow(book)
|
||||||
|
}
|
||||||
|
SearchBookListItem(
|
||||||
|
book = book,
|
||||||
|
shelfState = shelfStateFlow,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onIntent(SearchIntent.OpenSearchBook(book))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SearchResultFooter(
|
||||||
|
isSearching = state.isSearching,
|
||||||
|
hasMore = state.hasMore,
|
||||||
|
hasResult = true,
|
||||||
|
committedQuery = state.committedQuery,
|
||||||
|
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppAlertDialog(
|
||||||
|
show = state.showClearHistoryDialog,
|
||||||
|
onDismissRequest = {
|
||||||
|
viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
|
||||||
|
},
|
||||||
|
title = stringResource(R.string.draw),
|
||||||
|
text = stringResource(R.string.sure_clear_search_history),
|
||||||
|
confirmText = stringResource(R.string.ok),
|
||||||
|
onConfirm = { viewModel.onIntent(SearchIntent.ConfirmClearHistory) },
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
onDismiss = {
|
||||||
|
viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AppAlertDialog(
|
||||||
|
data = state.emptyScopeAction,
|
||||||
|
onDismissRequest = {
|
||||||
|
viewModel.onIntent(SearchIntent.DismissEmptyScopeAction)
|
||||||
|
},
|
||||||
|
title = stringResource(R.string.draw),
|
||||||
|
confirmText = stringResource(R.string.ok),
|
||||||
|
onConfirm = { viewModel.onIntent(SearchIntent.ConfirmEmptyScopeAction) },
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
onDismiss = { viewModel.onIntent(SearchIntent.DismissEmptyScopeAction) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = if (it.wasPrecisionSearch) {
|
||||||
|
"${it.scopeDisplay}分组搜索结果为空,是否关闭精准搜索?"
|
||||||
|
} else {
|
||||||
|
"${it.scopeDisplay}分组搜索结果为空,是否切换到全部分组?"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AppModalBottomSheet(
|
||||||
|
show = state.showScopeSheet,
|
||||||
|
onDismissRequest = { viewModel.onIntent(SearchIntent.SetScopeSheetVisible(false)) },
|
||||||
|
title = stringResource(R.string.search_select_group),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
SelectionItemCard(
|
||||||
|
title = stringResource(R.string.all_source),
|
||||||
|
isSelected = state.isAllScope,
|
||||||
|
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||||
|
inSelectionMode = true,
|
||||||
|
onToggleSelection = {
|
||||||
|
viewModel.onIntent(SearchIntent.SelectAllScope)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
AppTabRow(
|
||||||
|
tabTitles = listOf(
|
||||||
|
stringResource(R.string.group),
|
||||||
|
stringResource(R.string.book_source),
|
||||||
|
),
|
||||||
|
selectedTabIndex = scopeSheetTab,
|
||||||
|
onTabSelected = { scopeSheetTab = it },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
if (scopeSheetTab == 0) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(state.enabledGroups, key = { it }) {
|
||||||
|
val selected = !state.isSourceScope && state.scopeDisplayNames.contains(it)
|
||||||
|
SelectionItemCard(
|
||||||
|
title = it,
|
||||||
|
isSelected = selected,
|
||||||
|
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||||
|
inSelectionMode = true,
|
||||||
|
onToggleSelection = {
|
||||||
|
viewModel.onIntent(SearchIntent.ToggleScopeGroup(it))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (state.enabledSources.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_empty),
|
||||||
|
style = LegadoTheme.typography.bodyMedium,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(state.enabledSources, key = { it.bookSourceUrl }) {
|
||||||
|
val selected = state.selectedScopeSourceUrls.contains(it.bookSourceUrl)
|
||||||
|
SelectionItemCard(
|
||||||
|
title = it.bookSourceName,
|
||||||
|
subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
|
||||||
|
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||||
|
isSelected = selected,
|
||||||
|
inSelectionMode = true,
|
||||||
|
onToggleSelection = {
|
||||||
|
viewModel.onIntent(SearchIntent.ToggleScopeSource(it))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchSuggestionPanel(
|
||||||
|
state: SearchUiState,
|
||||||
|
onUseHistory: (String) -> Unit,
|
||||||
|
onDeleteHistory: (SearchKeyword) -> Unit,
|
||||||
|
onOpenBook: (BookShelfItem) -> Unit,
|
||||||
|
onClearHistory: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = adaptiveContentPadding(
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (state.bookshelfHints.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
AppIcon(Icons.Default.Book, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
AppText(
|
||||||
|
text = stringResource(R.string.bookshelf),
|
||||||
|
style = LegadoTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(state.bookshelfHints, key = { it.bookUrl }) { book ->
|
||||||
|
SelectionItemCard(
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
title = book.name,
|
||||||
|
subtitle = book.author,
|
||||||
|
onToggleSelection = { onOpenBook(book) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
AppIcon(AppIcons.History, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
AppText(
|
||||||
|
text = stringResource(R.string.searchHistory),
|
||||||
|
style = LegadoTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.history.isNotEmpty()) {
|
||||||
|
SmallTextButton(
|
||||||
|
onClick = onClearHistory,
|
||||||
|
text = stringResource(R.string.clear_all),
|
||||||
|
imageVector = Icons.Default.Close
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.history.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_empty),
|
||||||
|
style = LegadoTheme.typography.bodyMedium,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(state.history, key = { it.word }) { history ->
|
||||||
|
SelectionItemCard(
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
title = history.word,
|
||||||
|
onToggleSelection = { onUseHistory(history.word) },
|
||||||
|
trailingAction = {
|
||||||
|
SmallIconButton(
|
||||||
|
onClick = { onDeleteHistory(history) },
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.delete)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultFooter(
|
||||||
|
isSearching: Boolean,
|
||||||
|
hasMore: Boolean,
|
||||||
|
hasResult: Boolean,
|
||||||
|
committedQuery: String,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
isSearching -> {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.width(18.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
AppText(text = stringResource(R.string.is_loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!hasResult && committedQuery.isNotBlank() -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_empty),
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_empty),
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_empty),
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package io.legado.app.ui.widget.components.book
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.legado.app.data.entities.SearchBook
|
||||||
|
import io.legado.app.model.BookShelfState
|
||||||
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.widget.components.cover.Cover
|
||||||
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchBookListItem(
|
||||||
|
book: SearchBook,
|
||||||
|
shelfState: Flow<BookShelfState>,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val currentShelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
|
||||||
|
|
||||||
|
val badgeContent: (@Composable RowScope.() -> Unit)? = when (currentShelfState) {
|
||||||
|
BookShelfState.IN_SHELF -> {
|
||||||
|
{
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "已在书架",
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BookShelfState.SAME_NAME_AUTHOR -> {
|
||||||
|
{
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = "同名书籍",
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BookShelfState.NOT_IN_SHELF -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Cover(
|
||||||
|
path = book.coverUrl,
|
||||||
|
modifier = Modifier.width(72.dp),
|
||||||
|
badgeContent = badgeContent,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
) {
|
||||||
|
AppText(
|
||||||
|
text = book.name,
|
||||||
|
style = LegadoTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
AppText(
|
||||||
|
text = book.author,
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
val latestChapter = book.latestChapterTitle
|
||||||
|
if (!latestChapter.isNullOrEmpty()) {
|
||||||
|
AppText(
|
||||||
|
text = " • ",
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
color = Color.Gray,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
AppText(
|
||||||
|
text = "最新: $latestChapter",
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
val intro = book.intro?.replace("\\s+".toRegex(), "") ?: ""
|
||||||
|
if (intro.isNotEmpty()) {
|
||||||
|
AppText(
|
||||||
|
text = intro,
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = Color.Gray,
|
||||||
|
maxLines = 2,
|
||||||
|
minLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kinds = book.getKindList()
|
||||||
|
if (kinds.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
kinds.forEach { kind ->
|
||||||
|
SearchBookTagChip(text = kind)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchBookGridItem(
|
||||||
|
book: SearchBook,
|
||||||
|
shelfState: Flow<BookShelfState>,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val currentShelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
|
||||||
|
|
||||||
|
val badgeText: String? = when (currentShelfState) {
|
||||||
|
BookShelfState.IN_SHELF -> "已在书架"
|
||||||
|
BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
|
||||||
|
BookShelfState.NOT_IN_SHELF -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val badgeContent: (@Composable RowScope.() -> Unit)? = if (!badgeText.isNullOrBlank()) {
|
||||||
|
{
|
||||||
|
AppText(
|
||||||
|
text = badgeText,
|
||||||
|
style = LegadoTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(IntrinsicSize.Min)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
Cover(
|
||||||
|
path = book.coverUrl,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(12 / 17f),
|
||||||
|
badgeContent = badgeContent,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
AppText(
|
||||||
|
text = book.name,
|
||||||
|
style = LegadoTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchBookTagChip(text: String) {
|
||||||
|
Surface(
|
||||||
|
color = LegadoTheme.colorScheme.cardContainer,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
) {
|
||||||
|
AppText(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = LegadoTheme.colorScheme.onCardContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user