diff --git a/.gitignore b/.gitignore index 33233646b..3d1e7d181 100644 --- a/.gitignore +++ b/.gitignore @@ -295,4 +295,5 @@ fabric.properties .idea/other.xml .idea/studiobot.xml -.kotlin \ No newline at end of file +.kotlin +keystore.properties diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index ae9abe1cc..f57aa1718 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -1,5 +1,6 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Properties import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -33,8 +34,8 @@ android { applicationId = "de.mm20.launcher2" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026053100 - versionName = "1.40.2" + versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091000 + versionName = "1.40.2-typing.1" signingConfig = signingConfigs.getByName("debug") } @@ -45,11 +46,31 @@ android { keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") } + + // Signing config for fork builds. Credentials are read from keystore.properties in the + // project root, or from the KEYSTORE_FILE, KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD + // environment variables. + create("local") { + val props = Properties() + val propsFile = rootProject.file("keystore.properties") + if (propsFile.exists()) { + propsFile.inputStream().use { props.load(it) } + } + storeFile = file( + props.getProperty("storeFile") + ?: System.getenv("KEYSTORE_FILE") + ?: "keystore.jks" + ) + storePassword = props.getProperty("storePassword") ?: System.getenv("KEYSTORE_PASSWORD") + keyAlias = props.getProperty("keyAlias") ?: System.getenv("KEY_ALIAS") + keyPassword = props.getProperty("keyPassword") ?: System.getenv("KEY_PASSWORD") + } } buildTypes { release { applicationIdSuffix = ".release" + signingConfig = signingConfigs.getByName("local") isMinifyEnabled = true isShrinkResources = true proguardFiles( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt index 95f4dcbf7..5480adcf4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,8 +40,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import de.mm20.launcher2.preferences.SearchBarStyle import de.mm20.launcher2.ui.R @@ -133,6 +136,17 @@ fun SearchBar( else 1f } + // The String flavour of BasicTextField keeps the old selection when the text is changed from + // the outside, which puts the cursor in front of the existing text. Examples are searches that + // were started by typing on a physical keyboard. A TextFieldValue lets us move the cursor to the + // end of the text instead. + val textFieldValue = remember { + mutableStateOf(TextFieldValue(value, TextRange(value.length))) + } + if (textFieldValue.value.text != value) { + textFieldValue.value = TextFieldValue(value, TextRange(value.length)) + } + LauncherCard( modifier = modifier .alpha(opacity), @@ -168,7 +182,7 @@ fun SearchBar( BasicTextField( modifier = Modifier .onFocusChanged { - if (it.hasFocus) onFocus() + if (it.hasFocus) onFocus() else onUnfocus() } .focusRequester(focusRequester) .fillMaxWidth() @@ -179,8 +193,11 @@ fun SearchBar( color = contentColor ), singleLine = true, - value = value, - onValueChange = onValueChange, + value = textFieldValue.value, + onValueChange = { + textFieldValue.value = it + onValueChange(it.text) + }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions( imeAction = ImeAction.Go, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt index 0a9f6581d..30e76daf5 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt @@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo import android.content.res.Configuration import android.content.res.Resources import android.os.Bundle +import android.view.KeyEvent import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels @@ -33,6 +34,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle @@ -466,6 +468,34 @@ abstract class SharedLauncherActivity( var pauseTime = 0L + /** + * Key event handler that is installed by the launcher scaffold while it is shown. It allows + * the launcher to react to key events that are not dispatched to any view, e.g. to open the + * search when the user starts typing on a physical keyboard. + * + * The handler runs on the main thread and returns true if it has consumed the event. + */ + internal var launcherKeyEventHandler: ((KeyEvent) -> Boolean)? = null + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val handler = launcherKeyEventHandler + if (handler != null) { + // dispatchKeyEvent must not throw, otherwise unhandled input would crash the launcher. + val handled = runCatching { handler(event) }.getOrDefault(false) + if (handled) return true + } + return super.dispatchKeyEvent(event) + } + + /** + * Hide the on-screen keyboard. Used when the search was opened by typing on a physical + * keyboard, where the soft keyboard would only cover the search results. + */ + internal fun hideSoftKeyboard() { + WindowCompat.getInsetsController(window, window.decorView) + .hide(WindowInsetsCompat.Type.ime()) + } + /** * True if the scaffold was on home screen when the activity was paused. */ diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt index 3cd38da1b..4d329457e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt @@ -50,6 +50,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -109,6 +110,7 @@ import de.mm20.launcher2.ui.launcher.scaffold.components.SearchComponent import de.mm20.launcher2.ui.launcher.search.SearchVM import de.mm20.launcher2.ui.launcher.search.filters.KeyboardFilterBar import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar +import de.mm20.launcher2.ui.launcher.searchbar.rememberTypeToSearchHandler import de.mm20.launcher2.ui.theme.transparency.transparency import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -325,6 +327,20 @@ internal class LauncherScaffoldState( var isSearchBarFocused by mutableStateOf(config.homeComponent is SearchComponent) + /** + * True while the search bar's text field actually has input focus in the Compose hierarchy. + * Unlike [isSearchBarFocused], which only says that the search bar *should* be focused, this is + * reported by the text field itself and is used to decide whether the text field is able to + * handle key events from a physical keyboard. + */ + var isSearchBarTextFieldFocused by mutableStateOf(false) + + /** + * True while the search is driven by the physical keyboard. The search bar's text field is not + * focused in that case, so that the on-screen keyboard stays hidden. + */ + var isTypingSearch by mutableStateOf(false) + val statusBarScrim by derivedStateOf { !isAtTop } @@ -1053,6 +1069,19 @@ internal fun LauncherScaffold( ) } + // Open the search when the user starts typing on a physical keyboard. The handler is + // installed on the activity, so it also receives key events while no text field is focused. + val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM) + DisposableEffect(typeToSearchHandler) { + val launcherActivity = activity as? SharedLauncherActivity + launcherActivity?.launcherKeyEventHandler = typeToSearchHandler + onDispose { + if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) { + launcherActivity.launcherKeyEventHandler = null + } + } + } + LaunchedEffect(state.isAtTop, state.isAtBottom) { if (state.currentProgress > 0f && state.currentProgress < 1f) { return@LaunchedEffect @@ -1372,10 +1401,14 @@ internal fun LauncherScaffold( level = { state.searchBarLevel }, bottomSearchBar = config.searchBarPosition == SearchBarPosition.Bottom, onFocusChange = { + state.isSearchBarTextFieldFocused = it if (it) { + // The user took over with the touch screen, so the text field behaves + // as usual from now on. + state.isTypingSearch = false scope.launch { state.onSearchBarTap() } + state.isSearchBarFocused = true } - state.isSearchBarFocused = it }, onKeyboardActionGo = if (launchOnEnter) { { searchVM.launchBestMatchOrAction(activity) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/components/SearchComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/components/SearchComponent.kt index 175020259..a94549243 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/components/SearchComponent.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/components/SearchComponent.kt @@ -105,7 +105,9 @@ internal class SearchComponent( override fun onPreActivate(state: LauncherScaffoldState) { super.onPreActivate(state) - if (openKeyboard) { + // When the search was started by typing on a physical keyboard, the text field stays + // unfocused so that the on-screen keyboard stays hidden. + if (openKeyboard && !state.isTypingSearch) { state.isSearchBarFocused = true } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index 709d64daa..833d65a33 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -74,6 +74,12 @@ class SearchVM : ViewModel(), KoinComponent { val launchOnEnter = searchUiSettings.launchOnEnter .stateIn(viewModelScope, SharingStarted.Eagerly, false) + /** + * Whether typing on a physical keyboard while the home screen is shown opens the search. + */ + val searchOnTyping = searchUiSettings.searchOnTyping + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + private val searchService: SearchService by inject() val searchQuery = mutableStateOf("") diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt new file mode 100644 index 000000000..7706f373f --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt @@ -0,0 +1,130 @@ +package de.mm20.launcher2.ui.launcher.searchbar + +import android.view.KeyEvent +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import de.mm20.launcher2.ui.launcher.SharedLauncherActivity +import de.mm20.launcher2.ui.launcher.scaffold.LauncherScaffoldState +import de.mm20.launcher2.ui.launcher.scaffold.components.SearchComponent +import de.mm20.launcher2.ui.launcher.search.SearchVM +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import de.mm20.launcher2.ui.overlays.LocalOverlayManager +import kotlinx.coroutines.launch + +/** + * The character that this key event types, or `null` if it is not a printable key press. + * + * Modifier keys, navigation keys, the backspace and delete keys as well as auto-repeat events + * return `null`. + */ +internal fun KeyEvent.typedCharacter(): Char? { + if (action != KeyEvent.ACTION_DOWN) return null + // Auto-repeat events are handled separately, see [isBackspace]. + if (repeatCount > 0) return null + if (isBackspace()) return null + val unicode = unicodeChar + if (unicode == 0) return null + // Control characters (enter, tab, escape, ctrl shortcuts, ...) are not typed into the search. + if (Character.isISOControl(unicode)) return null + return unicode.toChar() +} + +/** + * True for the backspace and delete keys. Auto-repeat is allowed, so that holding the key keeps + * deleting characters. + */ +internal fun KeyEvent.isBackspace(): Boolean { + if (action != KeyEvent.ACTION_DOWN) return false + return keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL +} + +/** + * True for the enter key. Used to launch the highlighted search result, like the "go" key on a + * software keyboard does. + */ +internal fun KeyEvent.isEnter(): Boolean { + if (action != KeyEvent.ACTION_DOWN) return false + if (repeatCount > 0) return false + return keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER +} + +/** + * Creates the key event handler that opens the search and starts typing when the user presses a key + * on a physical keyboard while the launcher home screen is shown. + * + * The handler is installed on the launcher activity, see + * [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the + * event is dispatched to the view hierarchy, so it also works while no text field has focus. + * + * While the search is driven by the physical keyboard, the search bar's text field is deliberately + * left unfocused: a focused text field would make the system show the on-screen keyboard, and the + * user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the + * characters itself. As soon as the user taps the search bar, the text field takes over and behaves + * like it always does. + * + * @return a handler that returns `true` if it has consumed the key event. + */ +@Composable +internal fun rememberTypeToSearchHandler( + state: LauncherScaffoldState, + searchVM: SearchVM, +): (KeyEvent) -> Boolean { + val scope = rememberCoroutineScope() + val activity = LocalActivity.current as? SharedLauncherActivity + val sheetManager = LocalBottomSheetManager.current + val overlayManager = LocalOverlayManager.current + + return remember(state, searchVM, activity, sheetManager, overlayManager) { + fun handle(event: KeyEvent): Boolean { + // Read the preference here rather than capturing it in the composition, so that the + // current value is always used. + if (!searchVM.searchOnTyping.value || state.isLocked) return false + + // The text field has the input focus and handles key events itself. This gives the user + // the usual editing behaviour, e.g. a text cursor that can be moved. + if (state.isSearchBarTextFieldFocused) return false + + // Only start a search from the home screen or while the search page is already open + // (the user may have scrolled the results away from the search bar). + val searchOpen = state.currentComponent is SearchComponent + val onHomeScreen = !state.isSettledOnSecondaryPage && state.currentProgress == 0f + if (!searchOpen && !onHomeScreen) return false + + // Never steal keys from a text field in a bottom sheet or an overlay. + if (sheetManager.isAnySheetShown()) return false + if (overlayManager.overlays.isNotEmpty()) return false + + val text = searchVM.searchQuery.value + val char = event.typedCharacter() + val newText = when { + char != null -> if (searchOpen) text + char else char.toString() + // Backspace and delete are only handled for a search that the keyboard started. + event.isBackspace() && state.isTypingSearch -> text.dropLast(1) + event.isEnter() && state.isTypingSearch -> { + if (searchVM.launchOnEnter.value) { + searchVM.launchBestMatchOrAction( + activity ?: return false + ) + } + return true + } + + else -> return false + } + + if (newText == text && char == null) return false + + if (!state.isTypingSearch) { + state.isTypingSearch = true + // In case the on-screen keyboard is still visible from an earlier session. + activity?.hideSoftKeyboard() + } + if (!searchOpen) scope.launch { state.onSearchBarTap() } + searchVM.search(newText) + return true + } + ::handle + } +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt index 03eaa14f7..c2e6eead1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt @@ -83,6 +83,16 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) : failedGestureSheetShown.value = null } + /** + * True if any bottom sheet is currently shown. + */ + fun isAnySheetShown(): Boolean = + customizeSearchableSheetShown.value != null + || editFavoritesSheetShown.value + || hiddenItemsSheetShown.value + || editTagSheetShown.value != null + || failedGestureSheetShown.value != null + companion object { private const val PROVIDER = "bottom_sheet_manager" private const val FAVORITES = "favorites" diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index 6da660205..f955f8156 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -89,6 +89,7 @@ fun SearchSettingsScreen() { val autoFocus by viewModel.autoFocus.collectAsStateWithLifecycle(null) val launchOnEnter by viewModel.launchOnEnter.collectAsStateWithLifecycle(null) + val searchOnTyping by viewModel.searchOnTyping.collectAsStateWithLifecycle(null) val reverseSearchResults by viewModel.reverseSearchResults.collectAsStateWithLifecycle(null) val filterBar by viewModel.filterBar.collectAsStateWithLifecycle(null) @@ -384,6 +385,15 @@ fun SearchSettingsScreen() { viewModel.setLaunchOnEnter(it) } ) + SwitchPreference( + title = stringResource(R.string.preference_search_bar_search_on_typing), + iconPadding = true, + summary = stringResource(R.string.preference_search_bar_search_on_typing_summary), + value = searchOnTyping == true, + onValueChanged = { + viewModel.setSearchOnTyping(it) + } + ) } } item { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt index 6bebc3e08..08f001cec 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -135,6 +135,13 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { searchUiSettings.setLaunchOnEnter(launchOnEnter) } + val searchOnTyping = searchUiSettings.searchOnTyping + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + fun setSearchOnTyping(searchOnTyping: Boolean) { + searchUiSettings.setSearchOnTyping(searchOnTyping) + } + val hasAppShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val appShortcuts = shortcutSearchSettings.enabled diff --git a/core/i18n/src/main/res/values-da/strings.xml b/core/i18n/src/main/res/values-da/strings.xml index eda41a3f6..3e72eccf1 100644 --- a/core/i18n/src/main/res/values-da/strings.xml +++ b/core/i18n/src/main/res/values-da/strings.xml @@ -425,6 +425,8 @@ Du har ikke tilknyttet en Nextcloud-konto endnu Flere oplysninger om denne udgave af denne app Start fremhævet match eller hurtig handling, når du trykker gå + Søg ved indtastning + Åbn søgningen og begynd at skrive, når du trykker på et fysisk tastatur Send besked Arbejde diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 0c62a7884..9c55ae9f6 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -675,6 +675,8 @@ Automatically show the keyboard when opening the app drawer Launch on enter Launch highlighted match or quick action upon tapping go + Search on typing + Open the search and start typing when you press a key on a physical keyboard Excluded search results Manage excluded apps and search results Show reveal button diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index fa6398e35..ca8267d7d 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -116,6 +116,11 @@ data class LauncherSettingsData internal constructor( val searchBarColors: SearchBarColors = SearchBarColors.Auto, val searchBarKeyboard: Boolean = true, val searchLaunchOnEnter: Boolean = true, + /** + * Open the search and start typing when a printable key is pressed on a physical keyboard + * while the home screen is shown. + */ + val searchOnTyping: Boolean = true, val searchBarBottom: Boolean = false, val searchBarFixed: Boolean = false, diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/SearchUiSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/SearchUiSettings.kt index 3b3e4828a..6d0689e9d 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/SearchUiSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/SearchUiSettings.kt @@ -52,6 +52,18 @@ class SearchUiSettings internal constructor( } } + /** + * Whether typing on a physical keyboard while the home screen is shown opens the search. + */ + val searchOnTyping + get() = launcherDataStore.data.map { it.searchOnTyping }.distinctUntilChanged() + + fun setSearchOnTyping(searchOnTyping: Boolean) { + launcherDataStore.update { + it.copy(searchOnTyping = searchOnTyping) + } + } + val reversedResults get() = launcherDataStore.data.map { it.searchResultsReversed }.distinctUntilChanged()