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
73384bc0eb
commit
cec914ed00
13 files changed
+426
-34
No files matched your search
@@ -21,6 +21,7 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Fast headword search across multiple DSL dictionaries.
|
- 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.
|
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
|
||||||
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
||||||
- Automatic extraction, DictZip compression, and indexing.
|
- Automatic extraction, DictZip compression, and indexing.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
viewModelFactory {
|
viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
|
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.example.research.common.ui
|
package com.example.research.common.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
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.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
fun Modifier.appTestTag(tag: String): Modifier = this
|
fun Modifier.appTestTag(tag: String): Modifier = this
|
||||||
.testTag(tag)
|
.testTag(tag)
|
||||||
@@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics(
|
|||||||
onClick()
|
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
|
||||||
|
)
|
||||||
@@ -39,6 +39,11 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||||
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||||
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
|
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 }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -222,6 +227,51 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchHistory: Flow<List<String>> = 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<String> {
|
||||||
|
if (value == null) return emptyList()
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<String>>(value)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sanitizeDictionarySources() {
|
suspend fun sanitizeDictionarySources() {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val currentSources = decodeSources(
|
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.onSuccess
|
||||||
import com.example.research.core.util.sanitizeQuery
|
import com.example.research.core.util.sanitizeQuery
|
||||||
import com.example.research.data.paging.IndexEntryPagingSource
|
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.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.ui.article.ArticleState
|
import com.example.research.ui.article.ArticleState
|
||||||
import com.example.research.ui.article.ArticleTextWarmUp
|
import com.example.research.ui.article.ArticleTextWarmUp
|
||||||
@@ -34,6 +35,7 @@ sealed interface SearchAction {
|
|||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val repository: LocalDictionaryRepository,
|
private val repository: LocalDictionaryRepository,
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
private val savedStateHandle: SavedStateHandle
|
private val savedStateHandle: SavedStateHandle
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ class SearchViewModel(
|
|||||||
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
||||||
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
|
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 val indexSearcher by lazy { repository.getIndexSearcher() }
|
||||||
private var indexWarmupJob: Job? = null
|
private var indexWarmupJob: Job? = null
|
||||||
private var debounceSaveQueryJob: Job? = null
|
private var debounceSaveQueryJob: Job? = null
|
||||||
@@ -126,6 +135,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
is SearchAction.ArticleClicked -> {
|
is SearchAction.ArticleClicked -> {
|
||||||
articleHistory.clear()
|
articleHistory.clear()
|
||||||
|
recordSearchHistory(action.entry.originalWord)
|
||||||
loadArticle(action.entry)
|
loadArticle(action.entry)
|
||||||
}
|
}
|
||||||
SearchAction.ClearArticle -> clearArticle()
|
SearchAction.ClearArticle -> clearArticle()
|
||||||
@@ -159,6 +169,27 @@ class SearchViewModel(
|
|||||||
mutableErrorMessage.value = null
|
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) {
|
fun saveScrollPosition(index: Int, offset: Int) {
|
||||||
mutableScrollPosition.value = index to offset
|
mutableScrollPosition.value = index to offset
|
||||||
}
|
}
|
||||||
@@ -281,6 +312,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
entry?.let {
|
entry?.let {
|
||||||
currentArticle?.let(articleHistory::addLast)
|
currentArticle?.let(articleHistory::addLast)
|
||||||
|
recordSearchHistory(sanitizedWord)
|
||||||
loadArticle(it)
|
loadArticle(it)
|
||||||
}
|
}
|
||||||
}.onError { message, exception ->
|
}.onError { message, exception ->
|
||||||
|
|||||||
@@ -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.ArticleContent
|
||||||
import com.example.research.ui.article.ArticleTitleBar
|
import com.example.research.ui.article.ArticleTitleBar
|
||||||
import com.example.research.ui.article.ArticleState
|
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
|
import com.example.research.ui.theme.Spacing
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@@ -27,6 +27,7 @@ fun AdaptiveMainScreen(
|
|||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
@@ -54,13 +55,13 @@ fun AdaptiveMainScreen(
|
|||||||
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
||||||
|
|
||||||
val searchBar: @Composable () -> Unit = {
|
val searchBar: @Composable () -> Unit = {
|
||||||
MainTopBar(
|
MainSearchBar(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
canSearch = canSearch,
|
||||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
searchHistory = searchHistory,
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
onAction = onAction,
|
||||||
onNavigateToSettings = onNavigateToSettings
|
onNavigateToSettings = onNavigateToSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ fun AdaptiveMainScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(
|
.then(
|
||||||
if (isSearchBarAtBottom) {
|
if (isSearchBarAtBottom) {
|
||||||
Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
|
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ sealed interface MainAction {
|
|||||||
data object ClearQuery : MainAction
|
data object ClearQuery : MainAction
|
||||||
data object ClearError : MainAction
|
data object ClearError : MainAction
|
||||||
data class SaveScrollPosition(val index: Int, val offset: Int) : 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
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,7 @@ fun MainRoute(
|
|||||||
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
||||||
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
||||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
||||||
|
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
@@ -51,17 +52,6 @@ fun MainRoute(
|
|||||||
val searchState = remember(searchViewModel) {
|
val searchState = remember(searchViewModel) {
|
||||||
TextFieldState(initialText = searchViewModel.searchQuery.value)
|
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) {
|
LaunchedEffect(searchState, searchQuery) {
|
||||||
if (searchState.text.toString() != searchQuery) {
|
if (searchState.text.toString() != searchQuery) {
|
||||||
@@ -97,6 +87,27 @@ fun MainRoute(
|
|||||||
val isWide = maxWidth >= 600.dp
|
val isWide = maxWidth >= 600.dp
|
||||||
val isLandscape = maxWidth > maxHeight
|
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) {
|
if (isWide) {
|
||||||
AdaptiveMainScreen(
|
AdaptiveMainScreen(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
@@ -106,6 +117,7 @@ fun MainRoute(
|
|||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
@@ -130,6 +142,7 @@ fun MainRoute(
|
|||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
onArticleClick = onArticleClick,
|
onArticleClick = onArticleClick,
|
||||||
@@ -139,6 +152,29 @@ fun MainRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MainSearchBar(
|
||||||
|
searchState: TextFieldState,
|
||||||
|
canSearch: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
|
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
|
@Composable
|
||||||
internal fun MainScreen(
|
internal fun MainScreen(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
@@ -147,19 +183,20 @@ internal fun MainScreen(
|
|||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
onArticleClick: (IndexEntry) -> Unit,
|
onArticleClick: (IndexEntry) -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val searchBar: @Composable () -> Unit = {
|
val searchBar: @Composable () -> Unit = {
|
||||||
MainTopBar(
|
MainSearchBar(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
canSearch = canSearch,
|
||||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
searchHistory = searchHistory,
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
onAction = onAction,
|
||||||
onNavigateToSettings = onNavigateToSettings
|
onNavigateToSettings = onNavigateToSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.testTag
|
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.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
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.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
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.common.ui.theme.dictionaryTitleLarge
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -75,6 +76,8 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val TopBarHeight = Spacing.topBarHeight
|
private val TopBarHeight = Spacing.topBarHeight
|
||||||
private val TopBarShape = RoundedCornerShape(28.dp)
|
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||||
|
private val HistorySheetChrome = 64.dp
|
||||||
|
private val HistorySheetMinHeight = 300.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainTopBar(
|
fun MainTopBar(
|
||||||
@@ -83,13 +86,24 @@ fun MainTopBar(
|
|||||||
isSearchBarAtBottom: Boolean = false,
|
isSearchBarAtBottom: Boolean = false,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
|
searchHistory: List<String>,
|
||||||
|
onHistoryEntryClick: (String) -> Unit,
|
||||||
|
onHistoryEntryRemove: (String) -> Unit,
|
||||||
|
onClearHistory: () -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val searchIcon = painterResource(R.drawable.ic_search)
|
|
||||||
val closeIcon = painterResource(R.drawable.ic_close)
|
val closeIcon = painterResource(R.drawable.ic_close)
|
||||||
|
val historyIcon = painterResource(R.drawable.ic_manage_search)
|
||||||
val settingsIcon = painterResource(R.drawable.ic_settings)
|
val settingsIcon = painterResource(R.drawable.ic_settings)
|
||||||
val clearLabel = stringResource(R.string.clear_search)
|
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) }
|
var topBarBottomPx by remember { mutableIntStateOf(0) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imeInsets = WindowInsets.ime
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
@@ -123,9 +137,21 @@ fun MainTopBar(
|
|||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onQueryChange = onQueryChange,
|
onQueryChange = onQueryChange,
|
||||||
onClearQuery = onClearQuery,
|
onClearQuery = onClearQuery,
|
||||||
searchIcon = searchIcon,
|
|
||||||
closeIcon = closeIcon,
|
closeIcon = closeIcon,
|
||||||
clearLabel = clearLabel,
|
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)
|
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
|
@Composable
|
||||||
@@ -157,9 +202,12 @@ private fun SearchField(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
searchIcon: androidx.compose.ui.graphics.painter.Painter,
|
closeIcon: Painter,
|
||||||
closeIcon: androidx.compose.ui.graphics.painter.Painter,
|
|
||||||
clearLabel: String,
|
clearLabel: String,
|
||||||
|
historyIcon: Painter,
|
||||||
|
historyLabel: String,
|
||||||
|
historyEnabled: Boolean,
|
||||||
|
onShowHistory: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -182,11 +230,16 @@ private fun SearchField(
|
|||||||
lineLimits = TextFieldLineLimits.SingleLine,
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = historyIcon,
|
||||||
contentDescription = null,
|
contentDescription = historyLabel,
|
||||||
|
tint = LocalContentColor.current.copy(
|
||||||
|
alpha = if (historyEnabled) 1f else 0.38f
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = 4.dp)
|
.offset(x = 4.dp)
|
||||||
.size(24.dp)
|
.size(28.dp)
|
||||||
|
.appTestTag("history_button")
|
||||||
|
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = if (searchState.text.isNotEmpty()) {
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
@@ -231,3 +284,50 @@ private fun SearchField(
|
|||||||
.semantics { testTagsAsResourceId = true }
|
.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,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<String>,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_top">Наверху</string>
|
||||||
<string name="search_bar_position_bottom">Внизу</string>
|
<string name="search_bar_position_bottom">Внизу</string>
|
||||||
<string name="clear_search">Очистить поиск</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="article_return_to_search">Вернуться к поиску</string>
|
||||||
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
||||||
<string name="error_loading_article">Ошибка загрузки: %1$s</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_top">Top</string>
|
||||||
<string name="search_bar_position_bottom">Bottom</string>
|
<string name="search_bar_position_bottom">Bottom</string>
|
||||||
<string name="clear_search">Clear search</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="article_return_to_search">Return to search</string>
|
||||||
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
||||||
<string name="error_loading_article">Loading error: %1$s</string>
|
<string name="error_loading_article">Loading error: %1$s</string>
|
||||||
|
|||||||
Reference in new issue
Block a user