diff --git a/README.md b/README.md index 01e258b..9a94a36 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f ## Features - Fast headword search across multiple DSL dictionaries. +- Search history in the empty search state: recent queries with per-entry removal and clear-all. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases. - Automatic extraction, DictZip compression, and indexing. diff --git a/app/src/main/java/com/example/research/MainActivity.kt b/app/src/main/java/com/example/research/MainActivity.kt index da64b93..ee6d360 100644 --- a/app/src/main/java/com/example/research/MainActivity.kt +++ b/app/src/main/java/com/example/research/MainActivity.kt @@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() { val app = application as ReSearchApplication viewModelFactory { initializer { - SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle()) + SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle()) } } } diff --git a/app/src/main/java/com/example/research/common/ui/ModifierExtensions.kt b/app/src/main/java/com/example/research/common/ui/ModifierExtensions.kt index 47f2253..38d9b7c 100644 --- a/app/src/main/java/com/example/research/common/ui/ModifierExtensions.kt +++ b/app/src/main/java/com/example/research/common/ui/ModifierExtensions.kt @@ -1,13 +1,17 @@ package com.example.research.common.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp fun Modifier.appTestTag(tag: String): Modifier = this .testTag(tag) @@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics( onClick() } } + +@Composable +fun Modifier.iconClickable( + enabled: Boolean = true, + onClick: () -> Unit +): Modifier = this.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = 20.dp), + enabled = enabled, + onClick = onClick +) diff --git a/app/src/main/java/com/example/research/data/local/preferences/PreferencesManager.kt b/app/src/main/java/com/example/research/data/local/preferences/PreferencesManager.kt index d9195f0..d7de776 100644 --- a/app/src/main/java/com/example/research/data/local/preferences/PreferencesManager.kt +++ b/app/src/main/java/com/example/research/data/local/preferences/PreferencesManager.kt @@ -39,6 +39,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 } @@ -222,6 +227,51 @@ class PreferencesManager(private val context: Context) { } } + val searchHistory: Flow> = context.dataStore.data + .map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] } + .distinctUntilChanged() + .map(::decodeSearchHistory) + + 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 { + if (value == null) return emptyList() + return try { + json.decodeFromString>(value) + } catch (_: Exception) { + emptyList() + } + } + suspend fun sanitizeDictionarySources() { context.dataStore.edit { preferences -> val currentSources = decodeSources( diff --git a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt index 12c26fb..c891afe 100644 --- a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt @@ -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(null) val errorMessage: StateFlow = mutableErrorMessage.asStateFlow() + val searchHistory: StateFlow> = 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,27 @@ 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 + if (searchHistory.value.firstOrNull() == trimmed) return + viewModelScope.launch { + preferencesManager.addSearchHistoryEntry(trimmed) + } + } + fun saveScrollPosition(index: Int, offset: Int) { mutableScrollPosition.value = index to offset } @@ -281,6 +312,7 @@ class SearchViewModel( } entry?.let { currentArticle?.let(articleHistory::addLast) + recordSearchHistory(sanitizedWord) loadArticle(it) } }.onError { message, exception -> diff --git a/app/src/main/java/com/example/research/ui/main/AdaptiveMainScreen.kt b/app/src/main/java/com/example/research/ui/main/AdaptiveMainScreen.kt index f5744ce..8856e35 100644 --- a/app/src/main/java/com/example/research/ui/main/AdaptiveMainScreen.kt +++ b/app/src/main/java/com/example/research/ui/main/AdaptiveMainScreen.kt @@ -14,7 +14,7 @@ import com.example.research.core.domain.model.IndexEntry import com.example.research.ui.article.ArticleContent import com.example.research.ui.article.ArticleTitleBar import com.example.research.ui.article.ArticleState -import com.example.research.ui.main.components.MainTopBar +import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.Spacing @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -27,6 +27,7 @@ fun AdaptiveMainScreen( results: LazyPagingItems, errorMessage: String?, showDictionaryName: Boolean, + searchHistory: List, articleState: ArticleState?, scrollPosition: Pair, onAction: (MainAction) -> Unit, @@ -54,13 +55,13 @@ fun AdaptiveMainScreen( val listEndPadding = if (isTwoPane) 8.dp else 16.dp val searchBar: @Composable () -> Unit = { - MainTopBar( + MainSearchBar( searchState = searchState, - enabled = canSearch, + canSearch = canSearch, isSearchBarAtBottom = isSearchBarAtBottom, - onQueryChange = { onAction(MainAction.QueryChanged(it)) }, - onClearQuery = { onAction(MainAction.ClearQuery) }, - onNavigateToSettings = onNavigateToSettings + searchHistory = searchHistory, + onAction = onAction, + onNavigateToSettings = onNavigateToSettings, ) } @@ -69,7 +70,7 @@ fun AdaptiveMainScreen( .fillMaxSize() .then( if (isSearchBarAtBottom) { - Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime)) + Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme) } else { Modifier } diff --git a/app/src/main/java/com/example/research/ui/main/MainAction.kt b/app/src/main/java/com/example/research/ui/main/MainAction.kt index 9cb7047..031e4c1 100644 --- a/app/src/main/java/com/example/research/ui/main/MainAction.kt +++ b/app/src/main/java/com/example/research/ui/main/MainAction.kt @@ -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 } diff --git a/app/src/main/java/com/example/research/ui/main/MainScreen.kt b/app/src/main/java/com/example/research/ui/main/MainScreen.kt index e93a96e..47704de 100644 --- a/app/src/main/java/com/example/research/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/research/ui/main/MainScreen.kt @@ -40,6 +40,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() @@ -51,17 +52,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) { @@ -97,6 +87,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, @@ -106,6 +117,7 @@ fun MainRoute( results = results, errorMessage = errorMessage, showDictionaryName = showDictionaryName, + searchHistory = searchHistory, articleState = articleState, scrollPosition = scrollPosition, onAction = onMainAction, @@ -130,6 +142,7 @@ fun MainRoute( results = results, errorMessage = errorMessage, showDictionaryName = showDictionaryName, + searchHistory = searchHistory, scrollPosition = scrollPosition, onAction = onMainAction, onArticleClick = onArticleClick, @@ -139,6 +152,29 @@ fun MainRoute( } } +@Composable +internal fun MainSearchBar( + searchState: TextFieldState, + canSearch: Boolean, + isSearchBarAtBottom: Boolean, + searchHistory: List, + onAction: (MainAction) -> Unit, + onNavigateToSettings: () -> Unit, +) { + MainTopBar( + searchState = searchState, + enabled = canSearch, + 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 + ) +} + @Composable internal fun MainScreen( searchState: TextFieldState, @@ -147,19 +183,20 @@ internal fun MainScreen( results: LazyPagingItems, errorMessage: String?, showDictionaryName: Boolean, + searchHistory: List, scrollPosition: Pair, onAction: (MainAction) -> Unit, onArticleClick: (IndexEntry) -> Unit, onNavigateToSettings: () -> Unit ) { val searchBar: @Composable () -> Unit = { - MainTopBar( + MainSearchBar( searchState = searchState, - enabled = canSearch, + canSearch = canSearch, isSearchBarAtBottom = isSearchBarAtBottom, - onQueryChange = { onAction(MainAction.QueryChanged(it)) }, - onClearQuery = { onAction(MainAction.ClearQuery) }, - onNavigateToSettings = onNavigateToSettings + searchHistory = searchHistory, + onAction = onAction, + onNavigateToSettings = onNavigateToSettings, ) } diff --git a/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt b/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt index 7c3a492..0d9354d 100644 --- a/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt +++ b/app/src/main/java/com/example/research/ui/main/components/MainTopBar.kt @@ -35,7 +35,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,14 +55,16 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager 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.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.research.R +import com.example.research.common.ui.appTestTag +import com.example.research.common.ui.iconClickable import com.example.research.common.ui.theme.dictionaryTitleLarge import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull @@ -75,6 +76,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 +86,24 @@ fun MainTopBar( isSearchBarAtBottom: Boolean = false, onQueryChange: (String) -> Unit, onClearQuery: () -> Unit, + searchHistory: List, + 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 Surface( color = MaterialTheme.colorScheme.surface, @@ -123,9 +137,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) ) @@ -148,6 +174,25 @@ fun MainTopBar( } } } + + if (showHistorySheet && searchHistory.isNotEmpty()) { + 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 @@ -157,9 +202,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 @@ -182,11 +230,16 @@ 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) + .appTestTag("history_button") + .iconClickable(enabled = historyEnabled, onClick = onShowHistory) ) }, trailingIcon = if (searchState.text.isNotEmpty()) { @@ -231,3 +284,50 @@ private fun SearchField( .semantics { testTagsAsResourceId = true } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchHistorySheet( + history: List, + 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(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) + ) + } +} diff --git a/app/src/main/java/com/example/research/ui/main/components/SearchHistoryList.kt b/app/src/main/java/com/example/research/ui/main/components/SearchHistoryList.kt new file mode 100644 index 0000000..8b01329 --- /dev/null +++ b/app/src/main/java/com/example/research/ui/main/components/SearchHistoryList.kt @@ -0,0 +1,138 @@ +package com.example.research.ui.main.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.research.R +import com.example.research.common.ui.appTestTag +import com.example.research.common.ui.iconClickable +import com.example.research.common.ui.theme.dictionaryTitleLarge +import com.example.research.ui.theme.Spacing + +@Composable +fun SearchHistoryList( + history: List, + onEntryClick: (String) -> Unit, + onEntryRemove: (String) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + modifier = modifier + .appTestTag("search_history"), + 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) + .appTestTag("clear_history") + .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() + .appTestTag("search_history_entry") + .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 + .appTestTag("remove_history_entry") + .iconClickable { onEntryRemove(entry) } + .size(20.dp) + ) + } + } +} diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..15a14ca --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b168be7..3c69942 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -16,6 +16,9 @@ Наверху Внизу Очистить поиск + История поиска + Очистить + Удалить из истории Вернуться к поиску Не удалось проверить статус словарей Ошибка загрузки: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8ecc00..fe14c7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,9 @@ Top Bottom Clear search + Search history + Clear + Remove from history Return to search Failed to check dictionary status Loading error: %1$s