[优化] 重写搜索界面
This commit is contained in:
@@ -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