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:
OneWay authored and Evgenii committed 2026-10-04 12:30:00 +00:00
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)
)
}
}
}
+9
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>