summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutHelper.kt1102
1 files changed, 1101 insertions, 1 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutHelper.kt b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutHelper.kt
index 0054dd772659..6395bb736a34 100644
--- a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutHelper.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutHelper.kt
@@ -1 +1,1101 @@
-/* * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.keyboard.shortcut.ui.composable import android.graphics.drawable.Icon import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItemColors import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed import com.android.compose.modifiers.thenIf import com.android.compose.ui.graphics.painter.rememberDrawablePainter import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCategoryType import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCommand import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCustomizationRequestInfo import com.android.systemui.keyboard.shortcut.shared.model.ShortcutIcon import com.android.systemui.keyboard.shortcut.shared.model.ShortcutKey import com.android.systemui.keyboard.shortcut.shared.model.ShortcutSubCategory import com.android.systemui.keyboard.shortcut.ui.model.IconSource import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCategoryUi import com.android.systemui.keyboard.shortcut.ui.model.ShortcutsUiState import com.android.systemui.res.R import kotlinx.coroutines.delay import com.android.systemui.keyboard.shortcut.shared.model.Shortcut as ShortcutModel @Composable fun ShortcutHelper( onSearchQueryChanged: (String) -> Unit, onKeyboardSettingsClicked: () -> Unit, modifier: Modifier = Modifier, shortcutsUiState: ShortcutsUiState, useSinglePane: @Composable () -> Boolean = { shouldUseSinglePane() }, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {}, ) { when (shortcutsUiState) { is ShortcutsUiState.Active -> { ActiveShortcutHelper( shortcutsUiState, useSinglePane, onSearchQueryChanged, modifier, onKeyboardSettingsClicked, onCustomizationRequested, ) } else -> { // No-op for now. } } } @Composable private fun ActiveShortcutHelper( shortcutsUiState: ShortcutsUiState.Active, useSinglePane: @Composable () -> Boolean, onSearchQueryChanged: (String) -> Unit, modifier: Modifier, onKeyboardSettingsClicked: () -> Unit, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {}, ) { var selectedCategoryType by remember(shortcutsUiState.defaultSelectedCategory) { mutableStateOf(shortcutsUiState.defaultSelectedCategory) } if (useSinglePane()) { ShortcutHelperSinglePane( shortcutsUiState.searchQuery, onSearchQueryChanged, shortcutsUiState.shortcutCategories, selectedCategoryType, onCategorySelected = { selectedCategoryType = it }, onKeyboardSettingsClicked, modifier, ) } else { ShortcutHelperTwoPane( shortcutsUiState.searchQuery, onSearchQueryChanged, modifier, shortcutsUiState.shortcutCategories, selectedCategoryType, onCategorySelected = { selectedCategoryType = it }, onKeyboardSettingsClicked, shortcutsUiState.isShortcutCustomizerFlagEnabled, onCustomizationRequested, shortcutsUiState.shouldShowResetButton, ) } } @Composable private fun shouldUseSinglePane() = hasCompactWindowSize() @Composable private fun ShortcutHelperSinglePane( searchQuery: String, onSearchQueryChanged: (String) -> Unit, categories: List<ShortcutCategoryUi>, selectedCategoryType: ShortcutCategoryType?, onCategorySelected: (ShortcutCategoryType?) -> Unit, onKeyboardSettingsClicked: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(start = 16.dp, end = 16.dp, top = 26.dp) ) { TitleBar() Spacer(modifier = Modifier.height(6.dp)) ShortcutsSearchBar(onSearchQueryChanged) Spacer(modifier = Modifier.height(16.dp)) if (categories.isEmpty()) { Box(modifier = Modifier.weight(1f)) { NoSearchResultsText(horizontalPadding = 16.dp, fillHeight = true) } } else { CategoriesPanelSinglePane( searchQuery, categories, selectedCategoryType, onCategorySelected, ) Spacer(modifier = Modifier.weight(1f)) } KeyboardSettings( horizontalPadding = 16.dp, verticalPadding = 32.dp, onClick = onKeyboardSettingsClicked, ) } } @Composable private fun CategoriesPanelSinglePane( searchQuery: String, categories: List<ShortcutCategoryUi>, selectedCategoryType: ShortcutCategoryType?, onCategorySelected: (ShortcutCategoryType?) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { categories.fastForEachIndexed { index, category -> val isExpanded = selectedCategoryType == category.type val itemShape = if (categories.size == 1) { ShortcutHelper.Shapes.singlePaneSingleCategory } else if (index == 0) { ShortcutHelper.Shapes.singlePaneFirstCategory } else if (index == categories.lastIndex) { ShortcutHelper.Shapes.singlePaneLastCategory } else { ShortcutHelper.Shapes.singlePaneCategory } CategoryItemSinglePane( searchQuery = searchQuery, category = category, isExpanded = isExpanded, onClick = { onCategorySelected( if (isExpanded) { null } else { category.type } ) }, shape = itemShape, ) } } } @Composable private fun CategoryItemSinglePane( searchQuery: String, category: ShortcutCategoryUi, isExpanded: Boolean, onClick: () -> Unit, shape: Shape, ) { Surface(color = MaterialTheme.colorScheme.surfaceBright, shape = shape, onClick = onClick) { Column { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp).padding(horizontal = 16.dp), ) { ShortcutCategoryIcon(modifier = Modifier.size(24.dp), source = category.iconSource) Spacer(modifier = Modifier.width(16.dp)) Text(category.label) Spacer(modifier = Modifier.weight(1f)) RotatingExpandCollapseIcon(isExpanded) } AnimatedVisibility(visible = isExpanded) { ShortcutCategoryDetailsSinglePane(searchQuery, category) } } } } @Composable fun ShortcutCategoryIcon( source: IconSource, modifier: Modifier = Modifier, contentDescription: String? = null, tint: Color = LocalContentColor.current, ) { if (source.imageVector != null) { Icon(source.imageVector, contentDescription, modifier, tint) } else if (source.painter != null) { Image(source.painter, contentDescription, modifier) } } @Composable private fun RotatingExpandCollapseIcon(isExpanded: Boolean) { val expandIconRotationDegrees by animateFloatAsState( targetValue = if (isExpanded) { 180f } else { 0f }, label = "Expand icon rotation animation", ) Icon( modifier = Modifier.background( color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = CircleShape, ) .graphicsLayer { rotationZ = expandIconRotationDegrees }, imageVector = Icons.Default.ExpandMore, contentDescription = if (isExpanded) { stringResource(R.string.shortcut_helper_content_description_collapse_icon) } else { stringResource(R.string.shortcut_helper_content_description_expand_icon) }, tint = MaterialTheme.colorScheme.onSurface, ) } @Composable private fun ShortcutCategoryDetailsSinglePane(searchQuery: String, category: ShortcutCategoryUi) { Column(Modifier.padding(horizontal = 16.dp)) { category.subCategories.fastForEach { subCategory -> ShortcutSubCategorySinglePane(searchQuery, subCategory) } } } @Composable private fun ShortcutSubCategorySinglePane(searchQuery: String, subCategory: ShortcutSubCategory) { // This @Composable is expected to be in a Column. SubCategoryTitle(subCategory.label) subCategory.shortcuts.fastForEachIndexed { index, shortcut -> if (index > 0) { HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHigh) } Shortcut(Modifier.padding(vertical = 24.dp), searchQuery, shortcut) } } @Composable private fun ShortcutHelperTwoPane( searchQuery: String, onSearchQueryChanged: (String) -> Unit, modifier: Modifier = Modifier, categories: List<ShortcutCategoryUi>, selectedCategoryType: ShortcutCategoryType?, onCategorySelected: (ShortcutCategoryType?) -> Unit, onKeyboardSettingsClicked: () -> Unit, isShortcutCustomizerFlagEnabled: Boolean, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {}, shouldShowResetButton: Boolean, ) { val selectedCategory = categories.fastFirstOrNull { it.type == selectedCategoryType } var isCustomizing by remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxSize().padding(horizontal = 24.dp)) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { // Keep title centered whether customize button is visible or not. Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { TitleBar(isCustomizing) } if (isShortcutCustomizerFlagEnabled) { CustomizationButtonsContainer( isCustomizing = isCustomizing, onToggleCustomizationMode = { isCustomizing = !isCustomizing }, onReset = { onCustomizationRequested(ShortcutCustomizationRequestInfo.Reset) }, shouldShowResetButton = shouldShowResetButton, ) } else { Spacer(modifier = Modifier.width(if (isCustomizing) 69.dp else 133.dp)) } } } Spacer(modifier = Modifier.height(12.dp)) Row(Modifier.fillMaxWidth()) { StartSidePanel( onSearchQueryChanged = onSearchQueryChanged, modifier = Modifier.width(240.dp).semantics { isTraversalGroup = true }, categories = categories, onKeyboardSettingsClicked = onKeyboardSettingsClicked, selectedCategory = selectedCategoryType, onCategoryClicked = { onCategorySelected(it.type) }, ) Spacer(modifier = Modifier.width(24.dp)) EndSidePanel( searchQuery, Modifier.fillMaxSize().padding(top = 8.dp).semantics { isTraversalGroup = true }, selectedCategory, isCustomizing = isCustomizing, onCustomizationRequested = onCustomizationRequested, ) } } } @Composable private fun CustomizationButtonsContainer( isCustomizing: Boolean, shouldShowResetButton: Boolean, onToggleCustomizationMode: () -> Unit, onReset: () -> Unit, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (isCustomizing) { if (shouldShowResetButton) { ResetButton(onClick = onReset) } DoneButton(onClick = onToggleCustomizationMode) } else { CustomizeButton(onClick = onToggleCustomizationMode) } } } @Composable private fun ResetButton(onClick: () -> Unit) { ShortcutHelperButton( modifier = Modifier.heightIn(40.dp), onClick = onClick, color = Color.Transparent, iconSource = IconSource(imageVector = Icons.Default.Refresh), text = stringResource(id = R.string.shortcut_helper_reset_button_text), contentColor = MaterialTheme.colorScheme.primary, border = BorderStroke(color = MaterialTheme.colorScheme.outlineVariant, width = 1.dp), ) } @Composable private fun CustomizeButton(onClick: () -> Unit) { ShortcutHelperButton( modifier = Modifier.heightIn(40.dp), onClick = onClick, color = MaterialTheme.colorScheme.secondaryContainer, iconSource = IconSource(imageVector = Icons.Default.Tune), text = stringResource(id = R.string.shortcut_helper_customize_button_text), contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) } @Composable private fun DoneButton(onClick: () -> Unit) { ShortcutHelperButton( modifier = Modifier.heightIn(40.dp), onClick = onClick, color = MaterialTheme.colorScheme.primary, text = stringResource(R.string.shortcut_helper_done_button_text), contentColor = MaterialTheme.colorScheme.onPrimary, ) } @Composable private fun EndSidePanel( searchQuery: String, modifier: Modifier, category: ShortcutCategoryUi?, isCustomizing: Boolean, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {}, ) { val listState = rememberLazyListState() LaunchedEffect(key1 = category) { if (category != null) listState.animateScrollToItem(0) } if (category == null) { NoSearchResultsText(horizontalPadding = 24.dp, fillHeight = false) return } LazyColumn(modifier = modifier, state = listState) { items(category.subCategories) { subcategory -> SubCategoryContainerDualPane( searchQuery = searchQuery, subCategory = subcategory, isCustomizing = isCustomizing and category.type.includeInCustomization, onCustomizationRequested = { requestInfo -> when (requestInfo) { is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add -> onCustomizationRequested(requestInfo.copy(categoryType = category.type)) is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete -> onCustomizationRequested(requestInfo.copy(categoryType = category.type)) ShortcutCustomizationRequestInfo.Reset -> onCustomizationRequested(requestInfo) } }, ) Spacer(modifier = Modifier.height(8.dp)) } } } @Composable private fun NoSearchResultsText(horizontalPadding: Dp, fillHeight: Boolean) { var modifier = Modifier.fillMaxWidth() if (fillHeight) { modifier = modifier.fillMaxHeight() } Text( stringResource(R.string.shortcut_helper_no_search_results), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = modifier .padding(vertical = 8.dp) .background(MaterialTheme.colorScheme.surfaceBright, RoundedCornerShape(28.dp)) .padding(horizontal = horizontalPadding, vertical = 24.dp), ) } @Composable private fun SubCategoryContainerDualPane( searchQuery: String, subCategory: ShortcutSubCategory, isCustomizing: Boolean, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit, ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceBright, ) { Column(Modifier.padding(16.dp)) { SubCategoryTitle(subCategory.label) Spacer(Modifier.height(8.dp)) subCategory.shortcuts.fastForEachIndexed { index, shortcut -> if (index > 0) { HorizontalDivider( modifier = Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, ) } Shortcut( modifier = Modifier.padding(vertical = 8.dp), searchQuery = searchQuery, shortcut = shortcut, isCustomizing = isCustomizing && shortcut.isCustomizable, onCustomizationRequested = { requestInfo -> when (requestInfo) { is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add -> onCustomizationRequested( requestInfo.copy(subCategoryLabel = subCategory.label) ) is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete -> onCustomizationRequested( requestInfo.copy(subCategoryLabel = subCategory.label) ) ShortcutCustomizationRequestInfo.Reset -> onCustomizationRequested(requestInfo) } }, ) } } } } @Composable private fun SubCategoryTitle(title: String) { Text( title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) } @Composable private fun Shortcut( modifier: Modifier, searchQuery: String, shortcut: ShortcutModel, isCustomizing: Boolean = false, onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {}, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val focusColor = MaterialTheme.colorScheme.secondary Row( modifier .thenIf(isFocused) { Modifier.border(width = 3.dp, color = focusColor, shape = RoundedCornerShape(16.dp)) } .focusable(interactionSource = interactionSource) .padding(8.dp) .semantics(mergeDescendants = true) { contentDescription = shortcut.contentDescription } ) { Row( modifier = Modifier.width(128.dp).align(Alignment.CenterVertically).weight(0.333f).semantics { hideFromAccessibility() }, horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (shortcut.icon != null) { ShortcutIcon( shortcut.icon, modifier = Modifier.size(24.dp).semantics { hideFromAccessibility() }, ) } ShortcutDescriptionText( searchQuery = searchQuery, shortcut = shortcut, modifier = Modifier.semantics { hideFromAccessibility() }, ) } Spacer(modifier = Modifier.width(24.dp).semantics { hideFromAccessibility() }) ShortcutKeyCombinations( modifier = Modifier.weight(.666f).semantics { hideFromAccessibility() }, shortcut = shortcut, isCustomizing = isCustomizing, onAddShortcutRequested = { onCustomizationRequested( ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add( label = shortcut.label, shortcutCommand = shortcut.commands.first(), ) ) }, onDeleteShortcutRequested = { onCustomizationRequested( ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete( label = shortcut.label, shortcutCommand = shortcut.commands.first(), ) ) }, ) } } @Composable fun ShortcutIcon( icon: ShortcutIcon, modifier: Modifier = Modifier, contentDescription: String? = null, ) { val context = LocalContext.current val drawable = remember(icon.packageName, icon.resourceId) { Icon.createWithResource(icon.packageName, icon.resourceId).loadDrawable(context) } ?: return Image( painter = rememberDrawablePainter(drawable), contentDescription = contentDescription, modifier = modifier, ) } @OptIn(ExperimentalLayoutApi::class) @Composable private fun ShortcutKeyCombinations( modifier: Modifier = Modifier, shortcut: ShortcutModel, isCustomizing: Boolean = false, onAddShortcutRequested: () -> Unit = {}, onDeleteShortcutRequested: () -> Unit = {}, ) { FlowRow( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, ) { shortcut.commands.forEachIndexed { index, command -> if (index > 0) { ShortcutOrSeparator(spacing = 16.dp) } ShortcutCommandContainer(showBackground = command.isCustom) { ShortcutCommand(command) } } if (isCustomizing) { Spacer(modifier = Modifier.width(16.dp)) if (shortcut.containsCustomShortcutCommands) { DeleteShortcutButton(onDeleteShortcutRequested) } else { AddShortcutButton(onAddShortcutRequested) } } } } @Composable private fun AddShortcutButton(onClick: () -> Unit) { ShortcutHelperButton( modifier = Modifier.size(32.dp), onClick = onClick, color = Color.Transparent, iconSource = IconSource(imageVector = Icons.Default.Add), contentColor = MaterialTheme.colorScheme.primary, contentPaddingVertical = 0.dp, contentPaddingHorizontal = 0.dp, contentDescription = stringResource(R.string.shortcut_helper_add_shortcut_button_label), shape = CircleShape, border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline) ) } @Composable private fun DeleteShortcutButton(onClick: () -> Unit) { ShortcutHelperButton( modifier = Modifier.size(32.dp), onClick = onClick, color = Color.Transparent, iconSource = IconSource(imageVector = Icons.Default.DeleteOutline), contentColor = MaterialTheme.colorScheme.primary, contentPaddingVertical = 0.dp, contentPaddingHorizontal = 0.dp, contentDescription = stringResource(R.string.shortcut_helper_delete_shortcut_button_label), shape = CircleShape, border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline) ) } @Composable private fun ShortcutCommandContainer(showBackground: Boolean, content: @Composable () -> Unit) { if (showBackground) { Box( modifier = Modifier.wrapContentSize() .background( color = MaterialTheme.colorScheme.outlineVariant, shape = RoundedCornerShape(16.dp), ) .padding(4.dp) ) { content() } } else { content() } } @Composable private fun ShortcutCommand(command: ShortcutCommand) { Row { command.keys.forEachIndexed { keyIndex, key -> if (keyIndex > 0) { Spacer(Modifier.width(4.dp)) } ShortcutKeyContainer { if (key is ShortcutKey.Text) { ShortcutTextKey(key) } else if (key is ShortcutKey.Icon) { ShortcutIconKey(key) } } } } } @Composable private fun ShortcutKeyContainer(shortcutKeyContent: @Composable BoxScope.() -> Unit) { Box( modifier = Modifier.height(36.dp) .background( color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(12.dp), ) ) { shortcutKeyContent() } } @Composable private fun BoxScope.ShortcutTextKey(key: ShortcutKey.Text) { Text( text = key.value, modifier = Modifier.align(Alignment.Center).padding(horizontal = 12.dp).semantics { hideFromAccessibility() }, style = MaterialTheme.typography.titleSmall, ) } @Composable private fun BoxScope.ShortcutIconKey(key: ShortcutKey.Icon) { Icon( painter = when (key) { is ShortcutKey.Icon.ResIdIcon -> painterResource(key.drawableResId) is ShortcutKey.Icon.DrawableIcon -> rememberDrawablePainter(drawable = key.drawable) }, contentDescription = null, modifier = Modifier.align(Alignment.Center).padding(6.dp), ) } @OptIn(ExperimentalLayoutApi::class) @Composable private fun FlowRowScope.ShortcutOrSeparator(spacing: Dp) { Spacer(Modifier.width(spacing)) Text( text = stringResource(R.string.shortcut_helper_key_combinations_or_separator), modifier = Modifier.align(Alignment.CenterVertically).semantics { hideFromAccessibility() }, style = MaterialTheme.typography.titleSmall, ) Spacer(Modifier.width(spacing)) } @Composable private fun ShortcutDescriptionText( searchQuery: String, shortcut: ShortcutModel, modifier: Modifier = Modifier, ) { Text( modifier = modifier, text = textWithHighlightedSearchQuery(shortcut.label, searchQuery), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) } @Composable private fun textWithHighlightedSearchQuery(text: String, searchValue: String) = buildAnnotatedString { val searchIndex = text.lowercase().indexOf(searchValue.trim().lowercase()) val postSearchIndex = searchIndex + searchValue.trim().length if (searchIndex > 0) { val preSearchText = text.substring(0, searchIndex) append(preSearchText) } if (searchIndex >= 0) { val searchText = text.substring(searchIndex, postSearchIndex) withStyle(style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer)) { append(searchText) } if (postSearchIndex < text.length) { val postSearchText = text.substring(postSearchIndex) append(postSearchText) } } else { append(text) } } @Composable private fun StartSidePanel( onSearchQueryChanged: (String) -> Unit, modifier: Modifier, categories: List<ShortcutCategoryUi>, onKeyboardSettingsClicked: () -> Unit, selectedCategory: ShortcutCategoryType?, onCategoryClicked: (ShortcutCategoryUi) -> Unit, ) { CompositionLocalProvider( // Restrict system font scale increases up to a max so categories display correctly. LocalDensity provides Density( density = LocalDensity.current.density, fontScale = LocalDensity.current.fontScale.coerceIn(1f, 1.5f), ) ) { Column(modifier) { ShortcutsSearchBar(onSearchQueryChanged) Spacer(modifier = Modifier.heightIn(8.dp)) CategoriesPanelTwoPane(categories, selectedCategory, onCategoryClicked) Spacer(modifier = Modifier.weight(1f)) KeyboardSettings( horizontalPadding = 24.dp, verticalPadding = 24.dp, onKeyboardSettingsClicked, ) } } } @Composable private fun CategoriesPanelTwoPane( categories: List<ShortcutCategoryUi>, selectedCategory: ShortcutCategoryType?, onCategoryClicked: (ShortcutCategoryUi) -> Unit, ) { Column { categories.fastForEach { CategoryItemTwoPane( label = it.label, iconSource = it.iconSource, selected = selectedCategory == it.type, onClick = { onCategoryClicked(it) }, ) } } } @Composable private fun CategoryItemTwoPane( label: String, iconSource: IconSource, selected: Boolean, onClick: () -> Unit, colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(unselectedContainerColor = Color.Transparent), ) { SelectableShortcutSurface( selected = selected, onClick = onClick, modifier = Modifier.semantics { role = Role.Tab }.heightIn(min = 64.dp).fillMaxWidth(), shape = RoundedCornerShape(28.dp), color = colors.containerColor(selected).value, interactionsConfig = InteractionsConfig( hoverOverlayColor = MaterialTheme.colorScheme.onSurface, hoverOverlayAlpha = 0.11f, pressedOverlayColor = MaterialTheme.colorScheme.onSurface, pressedOverlayAlpha = 0.15f, focusOutlineColor = MaterialTheme.colorScheme.secondary, focusOutlineStrokeWidth = 3.dp, focusOutlinePadding = 2.dp, surfaceCornerRadius = 28.dp, focusOutlineCornerRadius = 33.dp, ), ) { Row(Modifier.padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically) { ShortcutCategoryIcon( modifier = Modifier.size(24.dp), source = iconSource, contentDescription = null, tint = colors.iconColor(selected).value, ) Spacer(Modifier.width(12.dp)) Box(Modifier.weight(1f)) { Text( fontSize = 18.sp, color = colors.textColor(selected).value, style = MaterialTheme.typography.titleSmall.copy(hyphens = Hyphens.Auto), text = label, ) } } } } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun TitleBar(isCustomizing: Boolean = false) { val text = if (isCustomizing) { stringResource(R.string.shortcut_helper_customize_mode_title) } else { stringResource(R.string.shortcut_helper_title) } CenterAlignedTopAppBar( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent), title = { Text( text = text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, ) }, windowInsets = WindowInsets(top = 0.dp, bottom = 0.dp, left = 0.dp, right = 0.dp), expandedHeight = 64.dp, ) } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun ShortcutsSearchBar(onQueryChange: (String) -> Unit) { // Using an "internal query" to make sure the SearchBar is immediately updated, otherwise // the cursor moves to the wrong position sometimes, when waiting for the query to come back // from the ViewModel. var queryInternal by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current LaunchedEffect(Unit) { // TODO(b/272065229): Added minor delay so TalkBack can take focus of search box by default, // remove when default a11y focus is fixed. delay(50) focusRequester.requestFocus() } SearchBar( modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).onKeyEvent { if (it.key == Key.DirectionDown) { focusManager.moveFocus(FocusDirection.Down) return@onKeyEvent true } else { return@onKeyEvent false } }, colors = SearchBarDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright), query = queryInternal, active = false, onActiveChange = {}, onQueryChange = { queryInternal = it onQueryChange(it) }, onSearch = {}, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, placeholder = { Text(text = stringResource(R.string.shortcut_helper_search_placeholder)) }, windowInsets = WindowInsets(top = 0.dp, bottom = 0.dp, left = 0.dp, right = 0.dp), content = {}, ) } @Composable private fun KeyboardSettings(horizontalPadding: Dp, verticalPadding: Dp, onClick: () -> Unit) { ClickableShortcutSurface( onClick = onClick, shape = RoundedCornerShape(24.dp), color = Color.Transparent, modifier = Modifier.semantics { role = Role.Button }.fillMaxWidth().padding(horizontal = 12.dp), interactionsConfig = InteractionsConfig( hoverOverlayColor = MaterialTheme.colorScheme.onSurface, hoverOverlayAlpha = 0.11f, pressedOverlayColor = MaterialTheme.colorScheme.onSurface, pressedOverlayAlpha = 0.15f, focusOutlineColor = MaterialTheme.colorScheme.secondary, focusOutlinePadding = 8.dp, focusOutlineStrokeWidth = 3.dp, surfaceCornerRadius = 24.dp, focusOutlineCornerRadius = 28.dp, hoverPadding = 8.dp, ), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringResource(id = R.string.shortcut_helper_keyboard_settings_buttons_label), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp, style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.weight(1f)) Icon( imageVector = Icons.AutoMirrored.Default.OpenInNew, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp), ) } } } object ShortcutHelper { object Shapes { val singlePaneFirstCategory = RoundedCornerShape( topStart = Dimensions.SinglePaneCategoryCornerRadius, topEnd = Dimensions.SinglePaneCategoryCornerRadius, ) val singlePaneLastCategory = RoundedCornerShape( bottomStart = Dimensions.SinglePaneCategoryCornerRadius, bottomEnd = Dimensions.SinglePaneCategoryCornerRadius, ) val singlePaneSingleCategory = RoundedCornerShape(size = Dimensions.SinglePaneCategoryCornerRadius) val singlePaneCategory = RectangleShape } object Dimensions { val SinglePaneCategoryCornerRadius = 28.dp } internal const val TAG = "ShortcutHelperUI" } \ No newline at end of file
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.keyboard.shortcut.ui.composable
+
+import android.graphics.drawable.Icon
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.FlowRowScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.DeleteOutline
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationDrawerItemColors
+import androidx.compose.material3.NavigationDrawerItemDefaults
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.hideFromAccessibility
+import androidx.compose.ui.semantics.isTraversalGroup
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.Hyphens
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import com.android.compose.modifiers.thenIf
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+import com.android.systemui.keyboard.shortcut.shared.model.Shortcut as ShortcutModel
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCategoryType
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCommand
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCustomizationRequestInfo
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutIcon
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutKey
+import com.android.systemui.keyboard.shortcut.shared.model.ShortcutSubCategory
+import com.android.systemui.keyboard.shortcut.ui.model.IconSource
+import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCategoryUi
+import com.android.systemui.keyboard.shortcut.ui.model.ShortcutsUiState
+import com.android.systemui.res.R
+import kotlinx.coroutines.delay
+
+@Composable
+fun ShortcutHelper(
+ onSearchQueryChanged: (String) -> Unit,
+ onKeyboardSettingsClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+ shortcutsUiState: ShortcutsUiState,
+ useSinglePane: @Composable () -> Boolean = { shouldUseSinglePane() },
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {},
+) {
+ when (shortcutsUiState) {
+ is ShortcutsUiState.Active -> {
+ ActiveShortcutHelper(
+ shortcutsUiState,
+ useSinglePane,
+ onSearchQueryChanged,
+ modifier,
+ onKeyboardSettingsClicked,
+ onCustomizationRequested,
+ )
+ }
+
+ else -> {
+ // No-op for now.
+ }
+ }
+}
+
+@Composable
+private fun ActiveShortcutHelper(
+ shortcutsUiState: ShortcutsUiState.Active,
+ useSinglePane: @Composable () -> Boolean,
+ onSearchQueryChanged: (String) -> Unit,
+ modifier: Modifier,
+ onKeyboardSettingsClicked: () -> Unit,
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {},
+) {
+ var selectedCategoryType by
+ remember(shortcutsUiState.defaultSelectedCategory) {
+ mutableStateOf(shortcutsUiState.defaultSelectedCategory)
+ }
+ if (useSinglePane()) {
+ ShortcutHelperSinglePane(
+ shortcutsUiState.searchQuery,
+ onSearchQueryChanged,
+ shortcutsUiState.shortcutCategories,
+ selectedCategoryType,
+ onCategorySelected = { selectedCategoryType = it },
+ onKeyboardSettingsClicked,
+ modifier,
+ )
+ } else {
+ ShortcutHelperTwoPane(
+ shortcutsUiState.searchQuery,
+ onSearchQueryChanged,
+ modifier,
+ shortcutsUiState.shortcutCategories,
+ selectedCategoryType,
+ onCategorySelected = { selectedCategoryType = it },
+ onKeyboardSettingsClicked,
+ shortcutsUiState.isShortcutCustomizerFlagEnabled,
+ onCustomizationRequested,
+ shortcutsUiState.shouldShowResetButton,
+ )
+ }
+}
+
+@Composable private fun shouldUseSinglePane() = hasCompactWindowSize()
+
+@Composable
+private fun ShortcutHelperSinglePane(
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ categories: List<ShortcutCategoryUi>,
+ selectedCategoryType: ShortcutCategoryType?,
+ onCategorySelected: (ShortcutCategoryType?) -> Unit,
+ onKeyboardSettingsClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(start = 16.dp, end = 16.dp, top = 26.dp)
+ ) {
+ TitleBar()
+ Spacer(modifier = Modifier.height(6.dp))
+ ShortcutsSearchBar(onSearchQueryChanged)
+ Spacer(modifier = Modifier.height(16.dp))
+ if (categories.isEmpty()) {
+ Box(modifier = Modifier.weight(1f)) {
+ NoSearchResultsText(horizontalPadding = 16.dp, fillHeight = true)
+ }
+ } else {
+ CategoriesPanelSinglePane(
+ searchQuery,
+ categories,
+ selectedCategoryType,
+ onCategorySelected,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ KeyboardSettings(
+ horizontalPadding = 16.dp,
+ verticalPadding = 32.dp,
+ onClick = onKeyboardSettingsClicked,
+ )
+ }
+}
+
+@Composable
+private fun CategoriesPanelSinglePane(
+ searchQuery: String,
+ categories: List<ShortcutCategoryUi>,
+ selectedCategoryType: ShortcutCategoryType?,
+ onCategorySelected: (ShortcutCategoryType?) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ categories.fastForEachIndexed { index, category ->
+ val isExpanded = selectedCategoryType == category.type
+ val itemShape =
+ if (categories.size == 1) {
+ ShortcutHelper.Shapes.singlePaneSingleCategory
+ } else if (index == 0) {
+ ShortcutHelper.Shapes.singlePaneFirstCategory
+ } else if (index == categories.lastIndex) {
+ ShortcutHelper.Shapes.singlePaneLastCategory
+ } else {
+ ShortcutHelper.Shapes.singlePaneCategory
+ }
+ CategoryItemSinglePane(
+ searchQuery = searchQuery,
+ category = category,
+ isExpanded = isExpanded,
+ onClick = {
+ onCategorySelected(
+ if (isExpanded) {
+ null
+ } else {
+ category.type
+ }
+ )
+ },
+ shape = itemShape,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CategoryItemSinglePane(
+ searchQuery: String,
+ category: ShortcutCategoryUi,
+ isExpanded: Boolean,
+ onClick: () -> Unit,
+ shape: Shape,
+) {
+ Surface(color = MaterialTheme.colorScheme.surfaceBright, shape = shape, onClick = onClick) {
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp).padding(horizontal = 16.dp),
+ ) {
+ ShortcutCategoryIcon(modifier = Modifier.size(24.dp), source = category.iconSource)
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(category.label)
+ Spacer(modifier = Modifier.weight(1f))
+ RotatingExpandCollapseIcon(isExpanded)
+ }
+ AnimatedVisibility(visible = isExpanded) {
+ ShortcutCategoryDetailsSinglePane(searchQuery, category)
+ }
+ }
+ }
+}
+
+@Composable
+fun ShortcutCategoryIcon(
+ source: IconSource,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+ tint: Color = LocalContentColor.current,
+) {
+ if (source.imageVector != null) {
+ Icon(source.imageVector, contentDescription, modifier, tint)
+ } else if (source.painter != null) {
+ Image(source.painter, contentDescription, modifier)
+ }
+}
+
+@Composable
+private fun RotatingExpandCollapseIcon(isExpanded: Boolean) {
+ val expandIconRotationDegrees by
+ animateFloatAsState(
+ targetValue =
+ if (isExpanded) {
+ 180f
+ } else {
+ 0f
+ },
+ label = "Expand icon rotation animation",
+ )
+ Icon(
+ modifier =
+ Modifier.background(
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shape = CircleShape,
+ )
+ .graphicsLayer { rotationZ = expandIconRotationDegrees },
+ imageVector = Icons.Default.ExpandMore,
+ contentDescription =
+ if (isExpanded) {
+ stringResource(R.string.shortcut_helper_content_description_collapse_icon)
+ } else {
+ stringResource(R.string.shortcut_helper_content_description_expand_icon)
+ },
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+@Composable
+private fun ShortcutCategoryDetailsSinglePane(searchQuery: String, category: ShortcutCategoryUi) {
+ Column(Modifier.padding(horizontal = 16.dp)) {
+ category.subCategories.fastForEach { subCategory ->
+ ShortcutSubCategorySinglePane(searchQuery, subCategory)
+ }
+ }
+}
+
+@Composable
+private fun ShortcutSubCategorySinglePane(searchQuery: String, subCategory: ShortcutSubCategory) {
+ // This @Composable is expected to be in a Column.
+ SubCategoryTitle(subCategory.label)
+ subCategory.shortcuts.fastForEachIndexed { index, shortcut ->
+ if (index > 0) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHigh)
+ }
+ Shortcut(Modifier.padding(vertical = 24.dp), searchQuery, shortcut)
+ }
+}
+
+@Composable
+private fun ShortcutHelperTwoPane(
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ categories: List<ShortcutCategoryUi>,
+ selectedCategoryType: ShortcutCategoryType?,
+ onCategorySelected: (ShortcutCategoryType?) -> Unit,
+ onKeyboardSettingsClicked: () -> Unit,
+ isShortcutCustomizerFlagEnabled: Boolean,
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {},
+ shouldShowResetButton: Boolean,
+) {
+ val selectedCategory = categories.fastFirstOrNull { it.type == selectedCategoryType }
+ var isCustomizing by remember { mutableStateOf(false) }
+
+ Column(modifier = modifier.fillMaxSize().padding(horizontal = 24.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ // Keep title centered whether customize button is visible or not.
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ TitleBar(isCustomizing)
+ }
+ if (isShortcutCustomizerFlagEnabled) {
+ CustomizationButtonsContainer(
+ isCustomizing = isCustomizing,
+ onToggleCustomizationMode = { isCustomizing = !isCustomizing },
+ onReset = {
+ onCustomizationRequested(ShortcutCustomizationRequestInfo.Reset)
+ },
+ shouldShowResetButton = shouldShowResetButton,
+ )
+ } else {
+ Spacer(modifier = Modifier.width(if (isCustomizing) 69.dp else 133.dp))
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth()) {
+ StartSidePanel(
+ onSearchQueryChanged = onSearchQueryChanged,
+ modifier = Modifier.width(240.dp).semantics { isTraversalGroup = true },
+ categories = categories,
+ onKeyboardSettingsClicked = onKeyboardSettingsClicked,
+ selectedCategory = selectedCategoryType,
+ onCategoryClicked = { onCategorySelected(it.type) },
+ )
+ Spacer(modifier = Modifier.width(24.dp))
+ EndSidePanel(
+ searchQuery,
+ Modifier.fillMaxSize().padding(top = 8.dp).semantics { isTraversalGroup = true },
+ selectedCategory,
+ isCustomizing = isCustomizing,
+ onCustomizationRequested = onCustomizationRequested,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CustomizationButtonsContainer(
+ isCustomizing: Boolean,
+ shouldShowResetButton: Boolean,
+ onToggleCustomizationMode: () -> Unit,
+ onReset: () -> Unit,
+) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (isCustomizing) {
+ if (shouldShowResetButton) {
+ ResetButton(onClick = onReset)
+ }
+ DoneButton(onClick = onToggleCustomizationMode)
+ } else {
+ CustomizeButton(onClick = onToggleCustomizationMode)
+ }
+ }
+}
+
+@Composable
+private fun ResetButton(onClick: () -> Unit) {
+ ShortcutHelperButton(
+ modifier = Modifier.heightIn(40.dp),
+ onClick = onClick,
+ color = Color.Transparent,
+ iconSource = IconSource(imageVector = Icons.Default.Refresh),
+ text = stringResource(id = R.string.shortcut_helper_reset_button_text),
+ contentColor = MaterialTheme.colorScheme.primary,
+ border = BorderStroke(color = MaterialTheme.colorScheme.outlineVariant, width = 1.dp),
+ )
+}
+
+@Composable
+private fun CustomizeButton(onClick: () -> Unit) {
+ ShortcutHelperButton(
+ modifier = Modifier.heightIn(40.dp),
+ onClick = onClick,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ iconSource = IconSource(imageVector = Icons.Default.Tune),
+ text = stringResource(id = R.string.shortcut_helper_customize_button_text),
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+}
+
+@Composable
+private fun DoneButton(onClick: () -> Unit) {
+ ShortcutHelperButton(
+ modifier = Modifier.heightIn(40.dp),
+ onClick = onClick,
+ color = MaterialTheme.colorScheme.primary,
+ text = stringResource(R.string.shortcut_helper_done_button_text),
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ )
+}
+
+@Composable
+private fun EndSidePanel(
+ searchQuery: String,
+ modifier: Modifier,
+ category: ShortcutCategoryUi?,
+ isCustomizing: Boolean,
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {},
+) {
+ val listState = rememberLazyListState()
+ LaunchedEffect(key1 = category) { if (category != null) listState.animateScrollToItem(0) }
+ if (category == null) {
+ NoSearchResultsText(horizontalPadding = 24.dp, fillHeight = false)
+ return
+ }
+ LazyColumn(modifier = modifier, state = listState) {
+ items(category.subCategories) { subcategory ->
+ SubCategoryContainerDualPane(
+ searchQuery = searchQuery,
+ subCategory = subcategory,
+ isCustomizing = isCustomizing and category.type.includeInCustomization,
+ onCustomizationRequested = { requestInfo ->
+ when (requestInfo) {
+ is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add ->
+ onCustomizationRequested(requestInfo.copy(categoryType = category.type))
+
+ is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete ->
+ onCustomizationRequested(requestInfo.copy(categoryType = category.type))
+
+ ShortcutCustomizationRequestInfo.Reset ->
+ onCustomizationRequested(requestInfo)
+ }
+ },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+}
+
+@Composable
+private fun NoSearchResultsText(horizontalPadding: Dp, fillHeight: Boolean) {
+ var modifier = Modifier.fillMaxWidth()
+ if (fillHeight) {
+ modifier = modifier.fillMaxHeight()
+ }
+ Text(
+ stringResource(R.string.shortcut_helper_no_search_results),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier =
+ modifier
+ .padding(vertical = 8.dp)
+ .background(MaterialTheme.colorScheme.surfaceBright, RoundedCornerShape(28.dp))
+ .padding(horizontal = horizontalPadding, vertical = 24.dp),
+ )
+}
+
+@Composable
+private fun SubCategoryContainerDualPane(
+ searchQuery: String,
+ subCategory: ShortcutSubCategory,
+ isCustomizing: Boolean,
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surfaceBright,
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ SubCategoryTitle(subCategory.label)
+ Spacer(Modifier.height(8.dp))
+ subCategory.shortcuts.fastForEachIndexed { index, shortcut ->
+ if (index > 0) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ )
+ }
+ Shortcut(
+ modifier = Modifier.padding(vertical = 8.dp),
+ searchQuery = searchQuery,
+ shortcut = shortcut,
+ isCustomizing = isCustomizing && shortcut.isCustomizable,
+ onCustomizationRequested = { requestInfo ->
+ when (requestInfo) {
+ is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add ->
+ onCustomizationRequested(
+ requestInfo.copy(subCategoryLabel = subCategory.label)
+ )
+
+ is ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete ->
+ onCustomizationRequested(
+ requestInfo.copy(subCategoryLabel = subCategory.label)
+ )
+
+ ShortcutCustomizationRequestInfo.Reset ->
+ onCustomizationRequested(requestInfo)
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubCategoryTitle(title: String) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+}
+
+@Composable
+private fun Shortcut(
+ modifier: Modifier,
+ searchQuery: String,
+ shortcut: ShortcutModel,
+ isCustomizing: Boolean = false,
+ onCustomizationRequested: (ShortcutCustomizationRequestInfo) -> Unit = {},
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val focusColor = MaterialTheme.colorScheme.secondary
+ Row(
+ modifier
+ .thenIf(isFocused) {
+ Modifier.border(width = 3.dp, color = focusColor, shape = RoundedCornerShape(16.dp))
+ }
+ .focusable(interactionSource = interactionSource)
+ .padding(8.dp)
+ .semantics(mergeDescendants = true) { contentDescription = shortcut.contentDescription }
+ ) {
+ Row(
+ modifier =
+ Modifier.width(128.dp).align(Alignment.CenterVertically).weight(0.333f).semantics {
+ hideFromAccessibility()
+ },
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (shortcut.icon != null) {
+ ShortcutIcon(
+ shortcut.icon,
+ modifier = Modifier.size(24.dp).semantics { hideFromAccessibility() },
+ )
+ }
+ ShortcutDescriptionText(
+ searchQuery = searchQuery,
+ shortcut = shortcut,
+ modifier = Modifier.semantics { hideFromAccessibility() },
+ )
+ }
+ Spacer(modifier = Modifier.width(24.dp).semantics { hideFromAccessibility() })
+ ShortcutKeyCombinations(
+ modifier = Modifier.weight(.666f).semantics { hideFromAccessibility() },
+ shortcut = shortcut,
+ isCustomizing = isCustomizing,
+ onAddShortcutRequested = {
+ onCustomizationRequested(
+ ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Add(
+ label = shortcut.label,
+ shortcutCommand = shortcut.commands.first(),
+ )
+ )
+ },
+ onDeleteShortcutRequested = {
+ onCustomizationRequested(
+ ShortcutCustomizationRequestInfo.SingleShortcutCustomization.Delete(
+ label = shortcut.label,
+ shortcutCommand = shortcut.commands.first(),
+ )
+ )
+ },
+ )
+ }
+}
+
+@Composable
+fun ShortcutIcon(
+ icon: ShortcutIcon,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+) {
+ val context = LocalContext.current
+ val drawable =
+ remember(icon.packageName, icon.resourceId) {
+ Icon.createWithResource(icon.packageName, icon.resourceId).loadDrawable(context)
+ } ?: return
+ Image(
+ painter = rememberDrawablePainter(drawable),
+ contentDescription = contentDescription,
+ modifier = modifier,
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ShortcutKeyCombinations(
+ modifier: Modifier = Modifier,
+ shortcut: ShortcutModel,
+ isCustomizing: Boolean = false,
+ onAddShortcutRequested: () -> Unit = {},
+ onDeleteShortcutRequested: () -> Unit = {},
+) {
+ FlowRow(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ itemVerticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End,
+ ) {
+ shortcut.commands.forEachIndexed { index, command ->
+ if (index > 0) {
+ ShortcutOrSeparator(spacing = 16.dp)
+ }
+ ShortcutCommandContainer(showBackground = command.isCustom) { ShortcutCommand(command) }
+ }
+
+ if (isCustomizing) Spacer(modifier = Modifier.width(16.dp))
+
+ AnimatedVisibility(visible = isCustomizing) {
+ if (shortcut.containsCustomShortcutCommands) {
+ DeleteShortcutButton(onDeleteShortcutRequested)
+ } else {
+ AddShortcutButton(onAddShortcutRequested)
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddShortcutButton(onClick: () -> Unit) {
+ ShortcutHelperButton(
+ modifier = Modifier.size(32.dp),
+ onClick = onClick,
+ color = Color.Transparent,
+ iconSource = IconSource(imageVector = Icons.Default.Add),
+ contentColor = MaterialTheme.colorScheme.primary,
+ contentPaddingVertical = 0.dp,
+ contentPaddingHorizontal = 0.dp,
+ contentDescription = stringResource(R.string.shortcut_helper_add_shortcut_button_label),
+ shape = CircleShape,
+ border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline),
+ )
+}
+
+@Composable
+private fun DeleteShortcutButton(onClick: () -> Unit) {
+ ShortcutHelperButton(
+ modifier = Modifier.size(32.dp),
+ onClick = onClick,
+ color = Color.Transparent,
+ iconSource = IconSource(imageVector = Icons.Default.DeleteOutline),
+ contentColor = MaterialTheme.colorScheme.primary,
+ contentPaddingVertical = 0.dp,
+ contentPaddingHorizontal = 0.dp,
+ contentDescription = stringResource(R.string.shortcut_helper_delete_shortcut_button_label),
+ shape = CircleShape,
+ border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline),
+ )
+}
+
+@Composable
+private fun ShortcutCommandContainer(showBackground: Boolean, content: @Composable () -> Unit) {
+ if (showBackground) {
+ Box(
+ modifier =
+ Modifier.wrapContentSize()
+ .background(
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = RoundedCornerShape(16.dp),
+ )
+ .padding(4.dp)
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+}
+
+@Composable
+private fun ShortcutCommand(command: ShortcutCommand) {
+ Row {
+ command.keys.forEachIndexed { keyIndex, key ->
+ if (keyIndex > 0) {
+ Spacer(Modifier.width(4.dp))
+ }
+ ShortcutKeyContainer {
+ if (key is ShortcutKey.Text) {
+ ShortcutTextKey(key)
+ } else if (key is ShortcutKey.Icon) {
+ ShortcutIconKey(key)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShortcutKeyContainer(shortcutKeyContent: @Composable BoxScope.() -> Unit) {
+ Box(
+ modifier =
+ Modifier.height(36.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = RoundedCornerShape(12.dp),
+ )
+ ) {
+ shortcutKeyContent()
+ }
+}
+
+@Composable
+private fun BoxScope.ShortcutTextKey(key: ShortcutKey.Text) {
+ Text(
+ text = key.value,
+ modifier =
+ Modifier.align(Alignment.Center).padding(horizontal = 12.dp).semantics {
+ hideFromAccessibility()
+ },
+ style = MaterialTheme.typography.titleSmall,
+ )
+}
+
+@Composable
+private fun BoxScope.ShortcutIconKey(key: ShortcutKey.Icon) {
+ Icon(
+ painter =
+ when (key) {
+ is ShortcutKey.Icon.ResIdIcon -> painterResource(key.drawableResId)
+ is ShortcutKey.Icon.DrawableIcon -> rememberDrawablePainter(drawable = key.drawable)
+ },
+ contentDescription = null,
+ modifier = Modifier.align(Alignment.Center).padding(6.dp),
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun FlowRowScope.ShortcutOrSeparator(spacing: Dp) {
+ Spacer(Modifier.width(spacing))
+ Text(
+ text = stringResource(R.string.shortcut_helper_key_combinations_or_separator),
+ modifier = Modifier.align(Alignment.CenterVertically).semantics { hideFromAccessibility() },
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(Modifier.width(spacing))
+}
+
+@Composable
+private fun ShortcutDescriptionText(
+ searchQuery: String,
+ shortcut: ShortcutModel,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ modifier = modifier,
+ text = textWithHighlightedSearchQuery(shortcut.label, searchQuery),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+@Composable
+private fun textWithHighlightedSearchQuery(text: String, searchValue: String) =
+ buildAnnotatedString {
+ val searchIndex = text.lowercase().indexOf(searchValue.trim().lowercase())
+ val postSearchIndex = searchIndex + searchValue.trim().length
+
+ if (searchIndex > 0) {
+ val preSearchText = text.substring(0, searchIndex)
+ append(preSearchText)
+ }
+ if (searchIndex >= 0) {
+ val searchText = text.substring(searchIndex, postSearchIndex)
+ withStyle(style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer)) {
+ append(searchText)
+ }
+ if (postSearchIndex < text.length) {
+ val postSearchText = text.substring(postSearchIndex)
+ append(postSearchText)
+ }
+ } else {
+ append(text)
+ }
+ }
+
+@Composable
+private fun StartSidePanel(
+ onSearchQueryChanged: (String) -> Unit,
+ modifier: Modifier,
+ categories: List<ShortcutCategoryUi>,
+ onKeyboardSettingsClicked: () -> Unit,
+ selectedCategory: ShortcutCategoryType?,
+ onCategoryClicked: (ShortcutCategoryUi) -> Unit,
+) {
+ CompositionLocalProvider(
+ // Restrict system font scale increases up to a max so categories display correctly.
+ LocalDensity provides
+ Density(
+ density = LocalDensity.current.density,
+ fontScale = LocalDensity.current.fontScale.coerceIn(1f, 1.5f),
+ )
+ ) {
+ Column(modifier) {
+ ShortcutsSearchBar(onSearchQueryChanged)
+ Spacer(modifier = Modifier.heightIn(8.dp))
+ CategoriesPanelTwoPane(categories, selectedCategory, onCategoryClicked)
+ Spacer(modifier = Modifier.weight(1f))
+ KeyboardSettings(
+ horizontalPadding = 24.dp,
+ verticalPadding = 24.dp,
+ onKeyboardSettingsClicked,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CategoriesPanelTwoPane(
+ categories: List<ShortcutCategoryUi>,
+ selectedCategory: ShortcutCategoryType?,
+ onCategoryClicked: (ShortcutCategoryUi) -> Unit,
+) {
+ Column {
+ categories.fastForEach {
+ CategoryItemTwoPane(
+ label = it.label,
+ iconSource = it.iconSource,
+ selected = selectedCategory == it.type,
+ onClick = { onCategoryClicked(it) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun CategoryItemTwoPane(
+ label: String,
+ iconSource: IconSource,
+ selected: Boolean,
+ onClick: () -> Unit,
+ colors: NavigationDrawerItemColors =
+ NavigationDrawerItemDefaults.colors(unselectedContainerColor = Color.Transparent),
+) {
+ SelectableShortcutSurface(
+ selected = selected,
+ onClick = onClick,
+ modifier = Modifier.semantics { role = Role.Tab }.heightIn(min = 64.dp).fillMaxWidth(),
+ shape = RoundedCornerShape(28.dp),
+ color = colors.containerColor(selected).value,
+ interactionsConfig =
+ InteractionsConfig(
+ hoverOverlayColor = MaterialTheme.colorScheme.onSurface,
+ hoverOverlayAlpha = 0.11f,
+ pressedOverlayColor = MaterialTheme.colorScheme.onSurface,
+ pressedOverlayAlpha = 0.15f,
+ focusOutlineColor = MaterialTheme.colorScheme.secondary,
+ focusOutlineStrokeWidth = 3.dp,
+ focusOutlinePadding = 2.dp,
+ surfaceCornerRadius = 28.dp,
+ focusOutlineCornerRadius = 33.dp,
+ ),
+ ) {
+ Row(Modifier.padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically) {
+ ShortcutCategoryIcon(
+ modifier = Modifier.size(24.dp),
+ source = iconSource,
+ contentDescription = null,
+ tint = colors.iconColor(selected).value,
+ )
+ Spacer(Modifier.width(12.dp))
+ Box(Modifier.weight(1f)) {
+ Text(
+ fontSize = 18.sp,
+ color = colors.textColor(selected).value,
+ style = MaterialTheme.typography.titleSmall.copy(hyphens = Hyphens.Auto),
+ text = label,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun TitleBar(isCustomizing: Boolean = false) {
+ val text =
+ if (isCustomizing) {
+ stringResource(R.string.shortcut_helper_customize_mode_title)
+ } else {
+ stringResource(R.string.shortcut_helper_title)
+ }
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent),
+ title = {
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ },
+ windowInsets = WindowInsets(top = 0.dp, bottom = 0.dp, left = 0.dp, right = 0.dp),
+ expandedHeight = 64.dp,
+ )
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun ShortcutsSearchBar(onQueryChange: (String) -> Unit) {
+ // Using an "internal query" to make sure the SearchBar is immediately updated, otherwise
+ // the cursor moves to the wrong position sometimes, when waiting for the query to come back
+ // from the ViewModel.
+ var queryInternal by remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ LaunchedEffect(Unit) {
+ // TODO(b/272065229): Added minor delay so TalkBack can take focus of search box by default,
+ // remove when default a11y focus is fixed.
+ delay(50)
+ focusRequester.requestFocus()
+ }
+ SearchBar(
+ modifier =
+ Modifier.fillMaxWidth().focusRequester(focusRequester).onKeyEvent {
+ if (it.key == Key.DirectionDown) {
+ focusManager.moveFocus(FocusDirection.Down)
+ return@onKeyEvent true
+ } else {
+ return@onKeyEvent false
+ }
+ },
+ colors = SearchBarDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright),
+ query = queryInternal,
+ active = false,
+ onActiveChange = {},
+ onQueryChange = {
+ queryInternal = it
+ onQueryChange(it)
+ },
+ onSearch = {},
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ placeholder = { Text(text = stringResource(R.string.shortcut_helper_search_placeholder)) },
+ windowInsets = WindowInsets(top = 0.dp, bottom = 0.dp, left = 0.dp, right = 0.dp),
+ content = {},
+ )
+}
+
+@Composable
+private fun KeyboardSettings(horizontalPadding: Dp, verticalPadding: Dp, onClick: () -> Unit) {
+ ClickableShortcutSurface(
+ onClick = onClick,
+ shape = RoundedCornerShape(24.dp),
+ color = Color.Transparent,
+ modifier =
+ Modifier.semantics { role = Role.Button }.fillMaxWidth().padding(horizontal = 12.dp),
+ interactionsConfig =
+ InteractionsConfig(
+ hoverOverlayColor = MaterialTheme.colorScheme.onSurface,
+ hoverOverlayAlpha = 0.11f,
+ pressedOverlayColor = MaterialTheme.colorScheme.onSurface,
+ pressedOverlayAlpha = 0.15f,
+ focusOutlineColor = MaterialTheme.colorScheme.secondary,
+ focusOutlinePadding = 8.dp,
+ focusOutlineStrokeWidth = 3.dp,
+ surfaceCornerRadius = 24.dp,
+ focusOutlineCornerRadius = 28.dp,
+ hoverPadding = 8.dp,
+ ),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text =
+ stringResource(id = R.string.shortcut_helper_keyboard_settings_buttons_label),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 16.sp,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.OpenInNew,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+object ShortcutHelper {
+
+ object Shapes {
+ val singlePaneFirstCategory =
+ RoundedCornerShape(
+ topStart = Dimensions.SinglePaneCategoryCornerRadius,
+ topEnd = Dimensions.SinglePaneCategoryCornerRadius,
+ )
+ val singlePaneLastCategory =
+ RoundedCornerShape(
+ bottomStart = Dimensions.SinglePaneCategoryCornerRadius,
+ bottomEnd = Dimensions.SinglePaneCategoryCornerRadius,
+ )
+ val singlePaneSingleCategory =
+ RoundedCornerShape(size = Dimensions.SinglePaneCategoryCornerRadius)
+ val singlePaneCategory = RectangleShape
+ }
+
+ object Dimensions {
+ val SinglePaneCategoryCornerRadius = 28.dp
+ }
+
+ internal const val TAG = "ShortcutHelperUI"
+}