feat(search): search history bottom sheet
Record queries on explicit search and keep up to 100 entries. Show them in a bottom sheet opened from the search bar, with per-entry removal and clear-all.
This commit is contained in:
1 parent
232c1955ec
commit
05f62a42c6
12 files changed
+399
-19
No files matched your search
@@ -45,7 +45,7 @@ class MainActivity : ComponentActivity() {
|
||||
val app = application as ReSearchApplication
|
||||
viewModelFactory {
|
||||
initializer {
|
||||
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
|
||||
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ class PreferencesManager(private val context: Context) {
|
||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
|
||||
val SEARCH_HISTORY = stringPreferencesKey("search_history")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_SEARCH_HISTORY_ENTRIES = 100
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -220,6 +225,51 @@ class PreferencesManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val searchHistory: Flow<List<String>> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||
}
|
||||
|
||||
suspend fun addSearchHistoryEntry(query: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||
.toMutableList()
|
||||
current.removeAll { it.equals(query, ignoreCase = true) }
|
||||
current.add(0, query)
|
||||
preferences[PreferencesKeys.SEARCH_HISTORY] =
|
||||
json.encodeToString(current.take(MAX_SEARCH_HISTORY_ENTRIES))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeSearchHistoryEntry(query: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||
.toMutableList()
|
||||
if (current.removeAll { it == query }) {
|
||||
if (current.isEmpty()) {
|
||||
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
|
||||
} else {
|
||||
preferences[PreferencesKeys.SEARCH_HISTORY] = json.encodeToString(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSearchHistory() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSearchHistory(value: String?): List<String> {
|
||||
if (value == null) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<String>>(value)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sanitizeDictionarySources() {
|
||||
context.dataStore.edit { preferences ->
|
||||
val currentSources = decodeSources(
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.example.research.core.util.onError
|
||||
import com.example.research.core.util.onSuccess
|
||||
import com.example.research.core.util.sanitizeQuery
|
||||
import com.example.research.data.paging.IndexEntryPagingSource
|
||||
import com.example.research.data.local.preferences.PreferencesManager
|
||||
import com.example.research.data.repository.LocalDictionaryRepository
|
||||
import com.example.research.ui.article.ArticleState
|
||||
import com.example.research.ui.article.ArticleTextWarmUp
|
||||
@@ -34,6 +35,7 @@ sealed interface SearchAction {
|
||||
class SearchViewModel(
|
||||
application: Application,
|
||||
private val repository: LocalDictionaryRepository,
|
||||
private val preferencesManager: PreferencesManager,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
@@ -51,6 +53,13 @@ class SearchViewModel(
|
||||
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
|
||||
|
||||
val searchHistory: StateFlow<List<String>> = preferencesManager.searchHistory
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val indexSearcher by lazy { repository.getIndexSearcher() }
|
||||
private var indexWarmupJob: Job? = null
|
||||
private var debounceSaveQueryJob: Job? = null
|
||||
@@ -126,6 +135,7 @@ class SearchViewModel(
|
||||
}
|
||||
is SearchAction.ArticleClicked -> {
|
||||
articleHistory.clear()
|
||||
recordSearchHistory(action.entry.originalWord)
|
||||
loadArticle(action.entry)
|
||||
}
|
||||
SearchAction.ClearArticle -> clearArticle()
|
||||
@@ -159,6 +169,26 @@ class SearchViewModel(
|
||||
mutableErrorMessage.value = null
|
||||
}
|
||||
|
||||
fun removeSearchHistoryEntry(query: String) {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.removeSearchHistoryEntry(query)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearchHistory() {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.clearSearchHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSearchHistory(query: String) {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
preferencesManager.addSearchHistoryEntry(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveScrollPosition(index: Int, offset: Int) {
|
||||
mutableScrollPosition.value = index to offset
|
||||
}
|
||||
@@ -281,6 +311,7 @@ class SearchViewModel(
|
||||
}
|
||||
entry?.let {
|
||||
currentArticle?.let(articleHistory::addLast)
|
||||
recordSearchHistory(sanitizedWord)
|
||||
loadArticle(it)
|
||||
}
|
||||
}.onError { message, exception ->
|
||||
|
||||
@@ -27,6 +27,7 @@ fun AdaptiveMainScreen(
|
||||
results: LazyPagingItems<IndexEntry>,
|
||||
errorMessage: String?,
|
||||
showDictionaryName: Boolean,
|
||||
searchHistory: List<String>,
|
||||
articleState: ArticleState?,
|
||||
scrollPosition: Pair<Int, Int>,
|
||||
onAction: (MainAction) -> Unit,
|
||||
@@ -60,6 +61,10 @@ fun AdaptiveMainScreen(
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||
searchHistory = searchHistory,
|
||||
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
|
||||
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
|
||||
onClearHistory = { onAction(MainAction.ClearHistory) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ sealed interface MainAction {
|
||||
data object ClearQuery : MainAction
|
||||
data object ClearError : MainAction
|
||||
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
|
||||
data class HistoryEntryClicked(val query: String) : MainAction
|
||||
data class RemoveHistoryEntry(val query: String) : MainAction
|
||||
data object ClearHistory : MainAction
|
||||
}
|
||||
@@ -44,6 +44,7 @@ fun MainRoute(
|
||||
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
||||
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
||||
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
|
||||
|
||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
@@ -59,17 +60,6 @@ fun MainRoute(
|
||||
val searchState = remember(searchViewModel) {
|
||||
TextFieldState(initialText = searchViewModel.searchQuery.value)
|
||||
}
|
||||
val onMainAction = remember(searchViewModel) {
|
||||
{ action: MainAction ->
|
||||
when (action) {
|
||||
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
|
||||
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
|
||||
MainAction.ClearError -> searchViewModel.clearError()
|
||||
is MainAction.SaveScrollPosition ->
|
||||
searchViewModel.saveScrollPosition(action.index, action.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchState, searchQuery) {
|
||||
if (searchState.text.toString() != searchQuery) {
|
||||
@@ -105,6 +95,27 @@ fun MainRoute(
|
||||
val isWide = maxWidth >= 600.dp
|
||||
val isLandscape = maxWidth > maxHeight
|
||||
|
||||
val onMainAction = remember(searchViewModel, isWide) {
|
||||
{ action: MainAction ->
|
||||
when (action) {
|
||||
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
|
||||
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
|
||||
MainAction.ClearError -> searchViewModel.clearError()
|
||||
is MainAction.SaveScrollPosition ->
|
||||
searchViewModel.saveScrollPosition(action.index, action.offset)
|
||||
is MainAction.HistoryEntryClicked ->
|
||||
if (isWide) {
|
||||
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
|
||||
} else {
|
||||
searchViewModel.onSearchQueryChanged(action.query)
|
||||
}
|
||||
is MainAction.RemoveHistoryEntry ->
|
||||
searchViewModel.removeSearchHistoryEntry(action.query)
|
||||
MainAction.ClearHistory -> searchViewModel.clearSearchHistory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isWide) {
|
||||
AdaptiveMainScreen(
|
||||
searchState = searchState,
|
||||
@@ -114,6 +125,7 @@ fun MainRoute(
|
||||
results = results,
|
||||
errorMessage = errorMessage,
|
||||
showDictionaryName = showDictionaryName,
|
||||
searchHistory = searchHistory,
|
||||
articleState = articleState,
|
||||
scrollPosition = scrollPosition,
|
||||
onAction = onMainAction,
|
||||
@@ -138,6 +150,7 @@ fun MainRoute(
|
||||
results = results,
|
||||
errorMessage = errorMessage,
|
||||
showDictionaryName = showDictionaryName,
|
||||
searchHistory = searchHistory,
|
||||
scrollPosition = scrollPosition,
|
||||
onAction = onMainAction,
|
||||
onArticleClick = onArticleClick,
|
||||
@@ -155,6 +168,7 @@ internal fun MainScreen(
|
||||
results: LazyPagingItems<IndexEntry>,
|
||||
errorMessage: String?,
|
||||
showDictionaryName: Boolean,
|
||||
searchHistory: List<String>,
|
||||
scrollPosition: Pair<Int, Int>,
|
||||
onAction: (MainAction) -> Unit,
|
||||
onArticleClick: (IndexEntry) -> Unit,
|
||||
@@ -167,6 +181,10 @@ internal fun MainScreen(
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||
searchHistory = searchHistory,
|
||||
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
|
||||
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
|
||||
onClearHistory = { onAction(MainAction.ClearHistory) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val TopBarHeight = Spacing.topBarHeight
|
||||
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||
private val HistorySheetChrome = 64.dp
|
||||
private val HistorySheetMinHeight = 300.dp
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(
|
||||
@@ -83,13 +85,28 @@ fun MainTopBar(
|
||||
isSearchBarAtBottom: Boolean = false,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
searchHistory: List<String>,
|
||||
onHistoryEntryClick: (String) -> Unit,
|
||||
onHistoryEntryRemove: (String) -> Unit,
|
||||
onClearHistory: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit
|
||||
) {
|
||||
val searchIcon = painterResource(R.drawable.ic_search)
|
||||
val closeIcon = painterResource(R.drawable.ic_close)
|
||||
val historyIcon = painterResource(R.drawable.ic_manage_search)
|
||||
val settingsIcon = painterResource(R.drawable.ic_settings)
|
||||
val clearLabel = stringResource(R.string.clear_search)
|
||||
val historyLabel = stringResource(R.string.recent_searches)
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var showHistorySheet by remember { mutableStateOf(false) }
|
||||
var topBarBottomPx by remember { mutableIntStateOf(0) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
|
||||
LaunchedEffect(searchHistory.isEmpty()) {
|
||||
if (searchHistory.isEmpty()) showHistorySheet = false
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
@@ -125,9 +142,21 @@ fun MainTopBar(
|
||||
enabled = enabled,
|
||||
onQueryChange = onQueryChange,
|
||||
onClearQuery = onClearQuery,
|
||||
searchIcon = searchIcon,
|
||||
closeIcon = closeIcon,
|
||||
clearLabel = clearLabel,
|
||||
historyIcon = historyIcon,
|
||||
historyLabel = historyLabel,
|
||||
historyEnabled = searchHistory.isNotEmpty(),
|
||||
onShowHistory = {
|
||||
focusManager.clearFocus(force = true)
|
||||
coroutineScope.launch {
|
||||
withTimeoutOrNull(350) {
|
||||
snapshotFlow { imeInsets.getBottom(density) > 0 }
|
||||
.first { visible -> !visible }
|
||||
}
|
||||
showHistorySheet = !showHistorySheet
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
@@ -150,6 +179,25 @@ fun MainTopBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showHistorySheet) {
|
||||
SearchHistorySheet(
|
||||
history = searchHistory,
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
topBarBottomPx = topBarBottomPx,
|
||||
onDismiss = { showHistorySheet = false },
|
||||
onEntryClick = { entry ->
|
||||
focusManager.clearFocus(force = true)
|
||||
showHistorySheet = false
|
||||
onHistoryEntryClick(entry)
|
||||
},
|
||||
onEntryRemove = onHistoryEntryRemove,
|
||||
onClearAll = {
|
||||
showHistorySheet = false
|
||||
onClearHistory()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -159,9 +207,12 @@ private fun SearchField(
|
||||
enabled: Boolean,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
searchIcon: androidx.compose.ui.graphics.painter.Painter,
|
||||
closeIcon: androidx.compose.ui.graphics.painter.Painter,
|
||||
closeIcon: Painter,
|
||||
clearLabel: String,
|
||||
historyIcon: Painter,
|
||||
historyLabel: String,
|
||||
historyEnabled: Boolean,
|
||||
onShowHistory: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
@@ -184,11 +235,21 @@ private fun SearchField(
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = searchIcon,
|
||||
contentDescription = null,
|
||||
painter = historyIcon,
|
||||
contentDescription = historyLabel,
|
||||
tint = LocalContentColor.current.copy(
|
||||
alpha = if (historyEnabled) 1f else 0.38f
|
||||
),
|
||||
modifier = Modifier
|
||||
.offset(x = 4.dp)
|
||||
.size(24.dp)
|
||||
.size(28.dp)
|
||||
.testTag("history_button")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(bounded = false, radius = 20.dp),
|
||||
enabled = historyEnabled
|
||||
) { onShowHistory() }
|
||||
)
|
||||
},
|
||||
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||
@@ -233,3 +294,50 @@ private fun SearchField(
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchHistorySheet(
|
||||
history: List<String>,
|
||||
isSearchBarAtBottom: Boolean,
|
||||
topBarBottomPx: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onEntryClick: (String) -> Unit,
|
||||
onEntryRemove: (String) -> Unit,
|
||||
onClearAll: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
var frozenHeight by remember { mutableStateOf<Dp?>(null) }
|
||||
if (frozenHeight == null) {
|
||||
frozenHeight = if (isSearchBarAtBottom) {
|
||||
configuration.screenHeightDp.dp -
|
||||
HistorySheetChrome -
|
||||
with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
} else {
|
||||
with(density) {
|
||||
(configuration.screenHeightDp.dp.toPx() - topBarBottomPx).toDp()
|
||||
}
|
||||
}
|
||||
}
|
||||
val maxContentHeight = frozenHeight!!
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||
modifier = Modifier.testTag("search_history_sheet")
|
||||
) {
|
||||
SearchHistoryList(
|
||||
history = history,
|
||||
onEntryClick = onEntryClick,
|
||||
onEntryRemove = onEntryRemove,
|
||||
onClearAll = onClearAll,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.example.research.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||
import com.example.research.ui.theme.Spacing
|
||||
|
||||
@Composable
|
||||
fun SearchHistoryList(
|
||||
history: List<String>,
|
||||
onEntryClick: (String) -> Unit,
|
||||
onEntryRemove: (String) -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.testTag("search_history")
|
||||
.semantics { testTagsAsResourceId = true },
|
||||
state = listState,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
item(key = "history_header", contentType = "history_header") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Spacing.listItemVertical),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.recent_searches),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.clear_search_history),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.padding(end = 18.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.testTag("clear_history")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
.clickable { onClearAll() }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = history,
|
||||
key = { it },
|
||||
contentType = { "history_entry" }
|
||||
) { entry ->
|
||||
SearchHistoryItem(
|
||||
entry = entry,
|
||||
onEntryClick = onEntryClick,
|
||||
onEntryRemove = onEntryRemove
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchHistoryItem(
|
||||
entry: String,
|
||||
onEntryClick: (String) -> Unit,
|
||||
onEntryRemove: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("search_history_entry")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
.clickable { onEntryClick(entry) }
|
||||
.padding(vertical = Spacing.listItemVertical),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_history),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = entry,
|
||||
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.width(Spacing.topBarHeight)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.remove_search_history_entry),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.testTag("remove_history_entry")
|
||||
.semantics { testTagsAsResourceId = true }
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(bounded = false, radius = 20.dp)
|
||||
) { onEntryRemove(entry) }
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,19.99 10.51,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3M12,8V13L16.28,15.54L17,14.33L13.5,12.25V8H12Z" />
|
||||
</vector>
|
||||
@@ -16,6 +16,9 @@
|
||||
<string name="search_bar_position_top">Наверху</string>
|
||||
<string name="search_bar_position_bottom">Внизу</string>
|
||||
<string name="clear_search">Очистить поиск</string>
|
||||
<string name="recent_searches">История поиска</string>
|
||||
<string name="clear_search_history">Очистить</string>
|
||||
<string name="remove_search_history_entry">Удалить из истории</string>
|
||||
<string name="article_return_to_search">Вернуться к поиску</string>
|
||||
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
||||
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<string name="search_bar_position_top">Top</string>
|
||||
<string name="search_bar_position_bottom">Bottom</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<string name="recent_searches">Search history</string>
|
||||
<string name="clear_search_history">Clear</string>
|
||||
<string name="remove_search_history_entry">Remove from history</string>
|
||||
<string name="article_return_to_search">Return to search</string>
|
||||
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
||||
<string name="error_loading_article">Loading error: %1$s</string>
|
||||
|
||||
Reference in new issue
Block a user