Add search filters

This commit is contained in:
MM20 2024-04-21 01:26:22 +02:00
parent 3c489fc648
commit 58ebd5646b
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
14 changed files with 574 additions and 98 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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()
}
}
}
}

View File

@ -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) },

View File

@ -0,0 +1,2 @@
package de.mm20.launcher2.ui.launcher.searchbar

View File

@ -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)
}

View File

@ -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)

View File

@ -915,4 +915,6 @@
<string name="clock_style_custom">Custom widget</string>
<string name="clock_variant_standard">Standard</string>
<string name="clock_variant_outlined">Outlined</string>
<string name="search_filter_tools">Tools</string>
<string name="search_filter_online">Online results</string>
</resources>

View File

@ -0,0 +1,5 @@
package de.mm20.launcher2.ktx
inline fun Boolean.toInt(): Int {
return if (this) 1 else 0
}

View File

@ -0,0 +1,23 @@
package de.mm20.launcher2.search
import de.mm20.launcher2.ktx.toInt
data class SearchFilters(
val allowNetwork: Boolean = false,
val hiddenItems: Boolean = false,
val apps: Boolean = true,
val websites: Boolean = true,
val articles: Boolean = true,
val places: Boolean = true,
val files: Boolean = true,
val shortcuts: Boolean = true,
val contacts: Boolean = true,
val events: Boolean = true,
val tools: Boolean = true,
) {
val allCategoriesEnabled
get() = apps && websites && articles && places && files && shortcuts && contacts && events && tools
val enabledCategories: Int
get() = apps.toInt() + websites.toInt() + articles.toInt() + places.toInt() + files.toInt() + shortcuts.toInt() + contacts.toInt() + events.toInt() + tools.toInt()
}

View File

@ -23,7 +23,7 @@ import kotlinx.coroutines.supervisorScope
interface SearchService {
fun search(
query: String,
allowNetwork: Boolean = false,
filters: SearchFilters,
): Flow<SearchResults>
}
@ -44,7 +44,7 @@ internal class SearchServiceImpl(
override fun search(
query: String,
allowNetwork: Boolean,
filters: SearchFilters,
): Flow<SearchResults> = channelFlow {
val results = MutableStateFlow(SearchResults())
supervisorScope {
@ -56,8 +56,9 @@ internal class SearchServiceImpl(
}
}
}
if (filters.apps) {
launch {
appRepository.search(query, allowNetwork)
appRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -65,8 +66,10 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.shortcuts) {
launch {
appShortcutRepository.search(query, allowNetwork)
appShortcutRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -74,8 +77,10 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.contacts) {
launch {
contactRepository.search(query, allowNetwork)
contactRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -83,8 +88,10 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.events) {
launch {
calendarRepository.search(query, allowNetwork)
calendarRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -92,6 +99,8 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.tools) {
launch {
calculatorRepository.search(query).collectLatest { r ->
results.update {
@ -109,8 +118,10 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.websites) {
launch {
websiteRepository.search(query, allowNetwork)
websiteRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -118,9 +129,11 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.articles) {
launch {
delay(750)
articleRepository.search(query, allowNetwork)
articleRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -128,8 +141,10 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.places) {
launch {
locationRepository.search(query, allowNetwork)
locationRepository.search(query, filters.allowNetwork)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
@ -137,10 +152,12 @@ internal class SearchServiceImpl(
}
}
}
}
if (filters.files) {
launch {
fileRepository.search(
query,
allowNetwork
filters.allowNetwork
)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
@ -149,6 +166,7 @@ internal class SearchServiceImpl(
}
}
}
}
launch {
customAttributesRepository.search(query)
.withCustomLabels(customAttributesRepository)