Add search filters

This commit is contained in:
MM20
2024-04-21 01:30:33 +02:00
parent 3c489fc648
commit 58ebd5646b
14 changed files with 574 additions and 98 deletions
@@ -199,7 +199,7 @@ fun AssistantScaffold(
},
actions = actions,
highlightedAction = searchVM.bestMatch.value as? SearchAction,
showHiddenItemsButton = true,
isSearchOpen = true,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.toList
import kotlinx.coroutines.Dispatchers
@@ -40,7 +41,7 @@ class SearchablePickerVM : ViewModel(), KoinComponent {
searchJob = viewModelScope.launch {
searchService.search(
query = query,
allowNetwork = true,
filters = SearchFilters(allowNetwork = true)
).collectLatest {
if (searchQuery != query) return@collectLatest
items = withContext(Dispatchers.Default) {
@@ -2,9 +2,7 @@ package de.mm20.launcher2.ui.launcher
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration
@@ -627,7 +625,7 @@ fun PagerScaffold(
},
actions = actions,
highlightedAction = searchVM.bestMatch.value as? SearchAction,
showHiddenItemsButton = isSearchOpen,
isSearchOpen = isSearchOpen,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
@@ -603,7 +603,7 @@ fun PullDownScaffold(
},
actions = actions,
highlightedAction = searchVM.bestMatch.value as? SearchAction,
showHiddenItemsButton = isSearchOpen,
isSearchOpen = isSearchOpen,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
@@ -27,6 +27,7 @@ import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.search.Website
import de.mm20.launcher2.search.data.Calculator
import de.mm20.launcher2.search.data.UnitConverter
@@ -90,6 +91,8 @@ class SearchVM : ViewModel(), KoinComponent {
val favoritesEnabled = searchUiSettings.favorites
val hideFavorites = mutableStateOf(false)
val filters = mutableStateOf(SearchFilters())
val separateWorkProfile = searchUiSettings.separateWorkProfile
private val hiddenItemKeys = searchableRepository
@@ -117,6 +120,11 @@ class SearchVM : ViewModel(), KoinComponent {
}
}
fun setFilters(filters: SearchFilters) {
this.filters.value = filters
search(searchQuery.value, forceRestart = true)
}
private var searchJob: Job? = null
fun search(query: String, forceRestart: Boolean = false) {
if (searchQuery.value == query && !forceRestart) return
@@ -124,9 +132,10 @@ class SearchVM : ViewModel(), KoinComponent {
isSearchEmpty.value = query.isEmpty()
hiddenResults.value = emptyList()
val filters = filters.value
if (isSearchEmpty.value)
bestMatch.value = null
try {
searchJob?.cancel()
} catch (_: CancellationException) {
@@ -136,7 +145,7 @@ class SearchVM : ViewModel(), KoinComponent {
searchUiSettings.resultOrder.collectLatest { resultOrder ->
searchService.search(
query,
allowNetwork = true,
filters = filters,
).collectLatest { results ->
var resultsList = withContext(Dispatchers.Default) {
listOfNotNull(
@@ -219,10 +228,9 @@ class SearchVM : ViewModel(), KoinComponent {
val actions = mutableListOf<SearchAction>()
for (r in resultsList) {
when {
r is SavableSearchable && hiddenKeys.contains(r.key) -> {
r is SavableSearchable && hiddenKeys.contains(r.key) && !filters.hiddenItems -> {
hidden.add(r)
}
r is Application && r.profile == AppProfile.Work -> workApps.add(r)
r is Application -> apps.add(r)
r is AppShortcut -> shortcuts.add(r)
@@ -0,0 +1,239 @@
package de.mm20.launcher2.ui.launcher.searchbar
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AppShortcut
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Handyman
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.Wikipedia
import de.mm20.launcher2.ui.overlays.Overlay
@Composable
fun KeyboardFilterBar(filters: SearchFilters, onFiltersChange: (SearchFilters) -> Unit) {
Overlay {
val allCategoriesEnabled = filters.allCategoriesEnabled
Box(modifier = Modifier
.fillMaxSize()
.imePadding(), contentAlignment = Alignment.BottomCenter) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
HorizontalDivider()
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = filters.allowNetwork,
onClick = {
onFiltersChange(filters.copy(allowNetwork = !filters.allowNetwork))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Language,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.search_filter_online)) }
)
VerticalDivider(
modifier = Modifier
.height(36.dp)
.padding(horizontal = 8.dp)
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.apps && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleApps())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Apps,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text("Apps") }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.files && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleFiles())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Description,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_files)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.contacts && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleContacts())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Person,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_contacts)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.events && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleEvents())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Today,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_calendar)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.shortcuts && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleShortcuts())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.AppShortcut,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_appshortcuts)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.articles && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleArticles())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Wikipedia,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_wikipedia)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.websites && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleWebsites())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Public,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_websites)) }
)
FilterChip(
modifier = Modifier.padding(end = 8.dp),
selected = filters.places && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.togglePlaces())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Place,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_search_locations)) }
)
FilterChip(
selected = filters.tools && !allCategoriesEnabled,
onClick = {
onFiltersChange(filters.toggleTools())
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Handyman,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.search_filter_tools)) }
)
VerticalDivider(
modifier = Modifier
.height(36.dp)
.padding(horizontal = 8.dp)
)
FilterChip(
selected = filters.hiddenItems,
onClick = {
onFiltersChange(filters.copy(hiddenItems = !filters.hiddenItems))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
label = { Text(stringResource(R.string.preference_hidden_items)) }
)
}
HorizontalDivider()
}
}
}
}
@@ -4,20 +4,29 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material.icons.rounded.FilterAlt
import androidx.compose.material3.Badge
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.searchactions.actions.SearchAction
@@ -37,7 +46,7 @@ fun LauncherSearchBar(
onFocusChange: (Boolean) -> Unit,
actions: List<SearchAction>,
highlightedAction: SearchAction?,
showHiddenItemsButton: Boolean = false,
isSearchOpen: Boolean = false,
reverse: Boolean = false,
darkColors: Boolean = false,
onKeyboardActionGo: (KeyboardActionScope.() -> Unit)? = null
@@ -57,6 +66,15 @@ fun LauncherSearchBar(
else focusManager.clearFocus()
}
if (isSearchOpen && WindowInsets.isImeVisible) {
KeyboardFilterBar(
filters = searchVM.filters.value,
onFiltersChange = {
searchVM.setFilters(it)
}
)
}
val _value = value()
SearchBar(
@@ -66,21 +84,37 @@ fun LauncherSearchBar(
darkColors = darkColors,
menu = {
AnimatedVisibility(
hiddenItemsButtonEnabled && showHiddenItemsButton && hiddenItems.isNotEmpty(),
isSearchOpen,
enter = scaleIn(tween(100)),
exit = scaleOut(tween(100))
) {
FilledIconButton(
onClick = { sheetManager.showHiddenItemsSheet() },
colors = if (sheetManager.hiddenItemsSheetShown.value) IconButtonDefaults.filledTonalIconButtonColors() else IconButtonDefaults.iconButtonColors()
onClick = { },
colors = IconButtonDefaults.iconButtonColors()
) {
Icon(imageVector = Icons.Rounded.VisibilityOff, contentDescription = null)
Box {
Icon(imageVector = Icons.Rounded.FilterAlt, contentDescription = null)
androidx.compose.animation.AnimatedVisibility(
!searchVM.filters.value.allCategoriesEnabled,
enter = scaleIn(tween(100)),
exit = scaleOut(tween(100)),
modifier = Modifier.align(Alignment.BottomEnd).offset(-3.dp, -3.dp)
) {
Badge(
containerColor = MaterialTheme.colorScheme.tertiary,
)
}
}
}
}
SearchBarMenu(searchBarValue = _value, onSearchBarValueChange = onValueChange)
},
actions = {
SearchBarActions(actions = actions, reverse = reverse, highlightedAction = highlightedAction)
SearchBarActions(
actions = actions,
reverse = reverse,
highlightedAction = highlightedAction
)
},
focusRequester = focusRequester,
onFocus = { onFocusChange(true) },
@@ -0,0 +1,2 @@
package de.mm20.launcher2.ui.launcher.searchbar
@@ -0,0 +1,146 @@
package de.mm20.launcher2.ui.launcher.searchbar
import de.mm20.launcher2.search.SearchFilters
fun SearchFilters.withAllCategories(): SearchFilters {
return copy(
apps = true,
websites = true,
articles = true,
places = true,
files = true,
shortcuts = true,
contacts = true,
events = true,
tools = true
)
}
fun SearchFilters.withOnlyCategory(
apps: Boolean = false,
websites: Boolean = false,
articles: Boolean = false,
places: Boolean = false,
files: Boolean = false,
shortcuts: Boolean = false,
contacts: Boolean = false,
events: Boolean = false,
utilities: Boolean = false
): SearchFilters {
return copy(
apps = apps,
websites = websites,
articles = articles,
places = places,
files = files,
shortcuts = shortcuts,
contacts = contacts,
events = events,
tools = utilities
)
}
/**
* Create a new [SearchFilters] object with the [apps] property update, according to the following rules:
* - If all categories are enabled, disable all categories except for apps.
* - If apps is the only enabled category, enable all categories.
* - Otherwise, toggle the apps category.
*/
fun SearchFilters.toggleApps(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(apps = true)
}
if (apps && enabledCategories == 1) {
return withAllCategories()
}
return copy(apps = !apps)
}
fun SearchFilters.toggleWebsites(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(websites = true)
}
if (websites && enabledCategories == 1) {
return withAllCategories()
}
return copy(websites = !websites)
}
fun SearchFilters.toggleArticles(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(articles = true)
}
if (articles && enabledCategories == 1) {
return withAllCategories()
}
return copy(articles = !articles)
}
fun SearchFilters.togglePlaces(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(places = true)
}
if (places && enabledCategories == 1) {
return withAllCategories()
}
return copy(places = !places)
}
fun SearchFilters.toggleFiles(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(files = true)
}
if (files && enabledCategories == 1) {
return withAllCategories()
}
return copy(files = !files)
}
fun SearchFilters.toggleShortcuts(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(shortcuts = true)
}
if (shortcuts && enabledCategories == 1) {
return withAllCategories()
}
return copy(shortcuts = !shortcuts)
}
fun SearchFilters.toggleContacts(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(contacts = true)
}
if (contacts && enabledCategories == 1) {
return withAllCategories()
}
return copy(contacts = !contacts)
}
fun SearchFilters.toggleEvents(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(events = true)
}
if (events && enabledCategories == 1) {
return withAllCategories()
}
return copy(events = !events)
}
fun SearchFilters.toggleTools(): SearchFilters {
if (allCategoriesEnabled) {
return withOnlyCategory(utilities = true)
}
if (tools && enabledCategories == 1) {
return withAllCategories()
}
return copy(tools = !tools)
}
@@ -195,7 +195,7 @@ fun SearchSettingsScreen() {
SwitchPreference(
title = stringResource(R.string.preference_search_websites),
summary = stringResource(R.string.preference_search_websites_summary),
icon = Icons.Rounded.Language,
icon = Icons.Rounded.Public,
value = websites == true,
onValueChanged = {
viewModel.setWebsites(it)