1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.keyboard.shortcut.ui.composable
18
19 import androidx.compose.foundation.BorderStroke
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.layout.Arrangement
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.foundation.layout.Column
24 import androidx.compose.foundation.layout.Row
25 import androidx.compose.foundation.layout.Spacer
26 import androidx.compose.foundation.layout.height
27 import androidx.compose.foundation.layout.heightIn
28 import androidx.compose.foundation.layout.padding
29 import androidx.compose.foundation.layout.size
30 import androidx.compose.foundation.layout.sizeIn
31 import androidx.compose.foundation.layout.width
32 import androidx.compose.foundation.layout.wrapContentSize
33 import androidx.compose.foundation.shape.RoundedCornerShape
34 import androidx.compose.material.icons.Icons
35 import androidx.compose.material.icons.filled.Add
36 import androidx.compose.material.icons.filled.ErrorOutline
37 import androidx.compose.material3.Icon
38 import androidx.compose.material3.MaterialTheme
39 import androidx.compose.material3.OutlinedTextField
40 import androidx.compose.material3.OutlinedTextFieldDefaults
41 import androidx.compose.material3.Text
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.LaunchedEffect
44 import androidx.compose.runtime.remember
45 import androidx.compose.ui.Alignment
46 import androidx.compose.ui.Modifier
47 import androidx.compose.ui.focus.FocusDirection
48 import androidx.compose.ui.focus.FocusRequester
49 import androidx.compose.ui.focus.focusProperties
50 import androidx.compose.ui.focus.focusRequester
51 import androidx.compose.ui.graphics.Color
52 import androidx.compose.ui.input.key.Key
53 import androidx.compose.ui.input.key.KeyEvent
54 import androidx.compose.ui.input.key.KeyEventType
55 import androidx.compose.ui.input.key.key
56 import androidx.compose.ui.input.key.onPreviewKeyEvent
57 import androidx.compose.ui.input.key.type
58 import androidx.compose.ui.platform.LocalFocusManager
59 import androidx.compose.ui.res.painterResource
60 import androidx.compose.ui.res.stringResource
61 import androidx.compose.ui.semantics.LiveRegionMode
62 import androidx.compose.ui.semantics.contentDescription
63 import androidx.compose.ui.semantics.hideFromAccessibility
64 import androidx.compose.ui.semantics.liveRegion
65 import androidx.compose.ui.semantics.semantics
66 import androidx.compose.ui.text.font.FontWeight
67 import androidx.compose.ui.text.style.TextAlign
68 import androidx.compose.ui.unit.dp
69 import androidx.compose.ui.unit.sp
70 import com.android.compose.ui.graphics.painter.rememberDrawablePainter
71 import com.android.systemui.keyboard.shortcut.shared.model.ShortcutKey
72 import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCustomizationUiState
73 import com.android.systemui.res.R
74
75 @Composable
76 fun ShortcutCustomizationDialog(
77 uiState: ShortcutCustomizationUiState,
78 modifier: Modifier = Modifier,
79 onShortcutKeyCombinationSelected: (KeyEvent) -> Boolean,
80 onCancel: () -> Unit,
81 onConfirmSetShortcut: () -> Unit,
82 onConfirmDeleteShortcut: () -> Unit,
83 onConfirmResetShortcut: () -> Unit,
84 onClearSelectedKeyCombination: () -> Unit,
85 ) {
86 when (uiState) {
87 is ShortcutCustomizationUiState.AddShortcutDialog -> {
88 AddShortcutDialog(
89 modifier,
90 uiState,
91 onShortcutKeyCombinationSelected,
92 onCancel,
93 onConfirmSetShortcut,
94 onClearSelectedKeyCombination,
95 )
96 }
97 is ShortcutCustomizationUiState.DeleteShortcutDialog -> {
98 DeleteShortcutDialog(modifier, onCancel, onConfirmDeleteShortcut)
99 }
100 is ShortcutCustomizationUiState.ResetShortcutDialog -> {
101 ResetShortcutDialog(modifier, onCancel, onConfirmResetShortcut)
102 }
103 else -> {
104 /* No-op */
105 }
106 }
107 }
108
109 @Composable
AddShortcutDialognull110 private fun AddShortcutDialog(
111 modifier: Modifier,
112 uiState: ShortcutCustomizationUiState.AddShortcutDialog,
113 onShortcutKeyCombinationSelected: (KeyEvent) -> Boolean,
114 onCancel: () -> Unit,
115 onConfirmSetShortcut: () -> Unit,
116 onClearSelectedKeyCombination: () -> Unit,
117 ) {
118 Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
119 Title(uiState.shortcutLabel)
120 Description(
121 text = stringResource(id = R.string.shortcut_customize_mode_add_shortcut_description)
122 )
123 PromptShortcutModifier(
124 modifier = Modifier.padding(top = 24.dp).sizeIn(minWidth = 131.dp, minHeight = 48.dp),
125 defaultModifierKey = uiState.defaultCustomShortcutModifierKey,
126 )
127 SelectedKeyCombinationContainer(
128 shouldShowError = uiState.errorMessage.isNotEmpty(),
129 onShortcutKeyCombinationSelected = onShortcutKeyCombinationSelected,
130 pressedKeys = uiState.pressedKeys,
131 contentDescription = uiState.pressedKeysDescription,
132 onConfirmSetShortcut = onConfirmSetShortcut,
133 onClearSelectedKeyCombination = onClearSelectedKeyCombination,
134 )
135 ErrorMessageContainer(uiState.errorMessage)
136 DialogButtons(
137 onCancel,
138 isConfirmButtonEnabled = uiState.pressedKeys.isNotEmpty(),
139 onConfirm = onConfirmSetShortcut,
140 confirmButtonText =
141 stringResource(R.string.shortcut_helper_customize_dialog_set_shortcut_button_label),
142 )
143 }
144 }
145
146 @Composable
DeleteShortcutDialognull147 private fun DeleteShortcutDialog(
148 modifier: Modifier,
149 onCancel: () -> Unit,
150 onConfirmDeleteShortcut: () -> Unit,
151 ) {
152 ConfirmationDialog(
153 modifier = modifier,
154 title = stringResource(id = R.string.shortcut_customize_mode_remove_shortcut_dialog_title),
155 description =
156 stringResource(id = R.string.shortcut_customize_mode_remove_shortcut_description),
157 confirmButtonText =
158 stringResource(R.string.shortcut_helper_customize_dialog_remove_button_label),
159 onCancel = onCancel,
160 onConfirm = onConfirmDeleteShortcut,
161 )
162 }
163
164 @Composable
ResetShortcutDialognull165 private fun ResetShortcutDialog(
166 modifier: Modifier,
167 onCancel: () -> Unit,
168 onConfirmResetShortcut: () -> Unit,
169 ) {
170 ConfirmationDialog(
171 modifier = modifier,
172 title = stringResource(id = R.string.shortcut_customize_mode_reset_shortcut_dialog_title),
173 description =
174 stringResource(id = R.string.shortcut_customize_mode_reset_shortcut_description),
175 confirmButtonText =
176 stringResource(R.string.shortcut_helper_customize_dialog_reset_button_label),
177 onCancel = onCancel,
178 onConfirm = onConfirmResetShortcut,
179 )
180 }
181
182 @Composable
ConfirmationDialognull183 private fun ConfirmationDialog(
184 modifier: Modifier,
185 title: String,
186 description: String,
187 confirmButtonText: String,
188 onConfirm: () -> Unit,
189 onCancel: () -> Unit,
190 ) {
191 Column(modifier) {
192 Title(title = title)
193 Description(text = description)
194 DialogButtons(
195 onCancel = onCancel,
196 onConfirm = onConfirm,
197 confirmButtonText = confirmButtonText,
198 )
199 }
200 }
201
202 @Composable
DialogButtonsnull203 private fun DialogButtons(
204 onCancel: () -> Unit,
205 isConfirmButtonEnabled: Boolean = true,
206 onConfirm: () -> Unit,
207 confirmButtonText: String,
208 ) {
209 val focusRequester = remember { FocusRequester() }
210 LaunchedEffect(Unit) { focusRequester.requestFocus() }
211
212 Row(
213 modifier =
214 Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp)
215 .sizeIn(minWidth = 316.dp, minHeight = 48.dp),
216 verticalAlignment = Alignment.Bottom,
217 horizontalArrangement = Arrangement.End,
218 ) {
219 ShortcutHelperButton(
220 shape = RoundedCornerShape(50.dp),
221 onClick = onCancel,
222 color = Color.Transparent,
223 modifier = Modifier.heightIn(40.dp),
224 contentColor = MaterialTheme.colorScheme.primary,
225 text = stringResource(R.string.shortcut_helper_customize_dialog_cancel_button_label),
226 border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant),
227 )
228 Spacer(modifier = Modifier.width(8.dp))
229 ShortcutHelperButton(
230 modifier =
231 Modifier.heightIn(40.dp).focusRequester(focusRequester).focusProperties {
232 canFocus = true
233 }, // enable focus on touch/click mode
234 onClick = onConfirm,
235 color = MaterialTheme.colorScheme.primary,
236 contentColor = MaterialTheme.colorScheme.onPrimary,
237 text = confirmButtonText,
238 enabled = isConfirmButtonEnabled,
239 )
240 }
241 }
242
243 @Composable
ErrorMessageContainernull244 private fun ErrorMessageContainer(errorMessage: String) {
245 if (errorMessage.isNotEmpty()) {
246 Box(
247 modifier =
248 Modifier.padding(horizontal = 16.dp).sizeIn(minWidth = 332.dp, minHeight = 40.dp)
249 ) {
250 Text(
251 text = errorMessage,
252 style = MaterialTheme.typography.bodyMedium,
253 fontSize = 14.sp,
254 lineHeight = 20.sp,
255 fontWeight = FontWeight.W500,
256 color = MaterialTheme.colorScheme.error,
257 modifier =
258 Modifier.padding(start = 24.dp).width(252.dp).semantics {
259 contentDescription = errorMessage
260 liveRegion = LiveRegionMode.Polite
261 },
262 )
263 }
264 }
265 }
266
267 @Composable
SelectedKeyCombinationContainernull268 private fun SelectedKeyCombinationContainer(
269 shouldShowError: Boolean,
270 onShortcutKeyCombinationSelected: (KeyEvent) -> Boolean,
271 pressedKeys: List<ShortcutKey>,
272 contentDescription: String,
273 onConfirmSetShortcut: () -> Unit,
274 onClearSelectedKeyCombination: () -> Unit,
275 ) {
276 val focusRequester = remember { FocusRequester() }
277 val focusManager = LocalFocusManager.current
278 LaunchedEffect(Unit) { focusRequester.requestFocus() }
279
280 OutlinedInputField(
281 modifier =
282 Modifier.padding(all = 16.dp)
283 .sizeIn(minWidth = 332.dp, minHeight = 56.dp)
284 .focusRequester(focusRequester)
285 .focusProperties { canFocus = true }
286 .onPreviewKeyEvent { keyEvent ->
287 val keyEventProcessed = onShortcutKeyCombinationSelected(keyEvent)
288 if (keyEventProcessed) {
289 true
290 } else {
291 if (keyEvent.type == KeyEventType.KeyUp) {
292 when (keyEvent.key) {
293 Key.Enter -> {
294 onConfirmSetShortcut()
295 return@onPreviewKeyEvent true
296 }
297 Key.Backspace -> {
298 onClearSelectedKeyCombination()
299 return@onPreviewKeyEvent true
300 }
301 Key.DirectionDown -> {
302 focusManager.moveFocus(FocusDirection.Down)
303 return@onPreviewKeyEvent true
304 }
305 else -> return@onPreviewKeyEvent false
306 }
307 } else false
308 }
309 },
310 trailingIcon = { ErrorIcon(shouldShowError) },
311 isError = shouldShowError,
312 placeholder = { PressKeyPrompt() },
313 content =
314 if (pressedKeys.isNotEmpty()) {
315 { PressedKeysTextContainer(pressedKeys) }
316 } else {
317 null
318 },
319 contentDescription = contentDescription,
320 )
321 }
322
323 @Composable
ErrorIconnull324 private fun ErrorIcon(shouldShowError: Boolean) {
325 if (shouldShowError) {
326 Icon(
327 imageVector = Icons.Default.ErrorOutline,
328 contentDescription = null,
329 modifier = Modifier.size(20.dp),
330 tint = MaterialTheme.colorScheme.error,
331 )
332 }
333 }
334
335 @Composable
PressedKeysTextContainernull336 private fun PressedKeysTextContainer(pressedKeys: List<ShortcutKey>) {
337 Row(
338 modifier = Modifier.semantics { hideFromAccessibility() },
339 verticalAlignment = Alignment.CenterVertically,
340 ) {
341 pressedKeys.forEachIndexed { keyIndex, key ->
342 if (keyIndex > 0) {
343 ShortcutKeySeparator()
344 }
345 if (key is ShortcutKey.Text) {
346 ShortcutTextKey(key)
347 } else if (key is ShortcutKey.Icon) {
348 ShortcutIconKey(key)
349 }
350 }
351 }
352 }
353
354 @Composable
ShortcutKeySeparatornull355 private fun ShortcutKeySeparator() {
356 Text(
357 text = stringResource(id = R.string.shortcut_helper_plus_symbol),
358 style = MaterialTheme.typography.titleSmall,
359 fontSize = 16.sp,
360 lineHeight = 24.sp,
361 color = MaterialTheme.colorScheme.onSurfaceVariant,
362 )
363 }
364
365 @Composable
ShortcutIconKeynull366 private fun ShortcutIconKey(key: ShortcutKey.Icon) {
367 Icon(
368 painter =
369 when (key) {
370 is ShortcutKey.Icon.ResIdIcon -> painterResource(key.drawableResId)
371 is ShortcutKey.Icon.DrawableIcon -> rememberDrawablePainter(drawable = key.drawable)
372 },
373 contentDescription = null,
374 modifier = Modifier.height(24.dp),
375 tint = MaterialTheme.colorScheme.onSurfaceVariant,
376 )
377 }
378
379 @Composable
PressKeyPromptnull380 private fun PressKeyPrompt() {
381 Text(
382 text = stringResource(id = R.string.shortcut_helper_add_shortcut_dialog_placeholder),
383 style = MaterialTheme.typography.titleSmall,
384 fontSize = 16.sp,
385 lineHeight = 24.sp,
386 color = MaterialTheme.colorScheme.onSurfaceVariant,
387 )
388 }
389
390 @Composable
ShortcutTextKeynull391 private fun ShortcutTextKey(key: ShortcutKey.Text) {
392 Text(
393 text = key.value,
394 style = MaterialTheme.typography.titleSmall,
395 fontSize = 16.sp,
396 lineHeight = 24.sp,
397 color = MaterialTheme.colorScheme.onSurfaceVariant,
398 )
399 }
400
401 @Composable
Titlenull402 private fun Title(title: String) {
403 Text(
404 text = title,
405 style = MaterialTheme.typography.headlineSmall,
406 fontSize = 24.sp,
407 modifier =
408 Modifier.padding(horizontal = 24.dp).width(316.dp).wrapContentSize(Alignment.Center),
409 color = MaterialTheme.colorScheme.onSurface,
410 lineHeight = 32.sp,
411 fontWeight = FontWeight.W400,
412 textAlign = TextAlign.Center,
413 )
414 }
415
416 @Composable
Descriptionnull417 private fun Description(text: String) {
418 Text(
419 text = text,
420 style = MaterialTheme.typography.bodyMedium,
421 modifier =
422 Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp)
423 .width(316.dp)
424 .wrapContentSize(Alignment.Center),
425 color = MaterialTheme.colorScheme.onSurfaceVariant,
426 textAlign = TextAlign.Center,
427 )
428 }
429
430 @Composable
PromptShortcutModifiernull431 private fun PromptShortcutModifier(
432 modifier: Modifier,
433 defaultModifierKey: ShortcutKey.Icon.ResIdIcon,
434 ) {
435 Row(
436 modifier = modifier,
437 horizontalArrangement = Arrangement.spacedBy(2.dp),
438 verticalAlignment = Alignment.CenterVertically,
439 ) {
440 ActionKeyContainer(defaultModifierKey)
441 PlusIconContainer()
442 }
443 }
444
445 @Composable
ActionKeyContainernull446 private fun ActionKeyContainer(defaultModifierKey: ShortcutKey.Icon.ResIdIcon) {
447 Row(
448 modifier =
449 Modifier.sizeIn(minWidth = 105.dp, minHeight = 48.dp)
450 .background(
451 color = MaterialTheme.colorScheme.surface,
452 shape = RoundedCornerShape(16.dp),
453 )
454 .padding(all = 12.dp),
455 horizontalArrangement = Arrangement.spacedBy(8.dp),
456 verticalAlignment = Alignment.CenterVertically,
457 ) {
458 ActionKeyIcon(defaultModifierKey)
459 ActionKeyText()
460 }
461 }
462
463 @Composable
ActionKeyTextnull464 private fun ActionKeyText() {
465 Text(
466 text = "Action",
467 style = MaterialTheme.typography.titleMedium,
468 fontSize = 16.sp,
469 lineHeight = 24.sp,
470 modifier = Modifier.wrapContentSize(Alignment.Center),
471 color = MaterialTheme.colorScheme.onSurface,
472 )
473 }
474
475 @Composable
ActionKeyIconnull476 private fun ActionKeyIcon(defaultModifierKey: ShortcutKey.Icon.ResIdIcon) {
477 Icon(
478 painter = painterResource(id = defaultModifierKey.drawableResId),
479 contentDescription = stringResource(R.string.shortcut_helper_content_description_meta_key),
480 modifier = Modifier.size(24.dp).wrapContentSize(Alignment.Center),
481 )
482 }
483
484 @Composable
PlusIconContainernull485 private fun PlusIconContainer() {
486 Icon(
487 tint = MaterialTheme.colorScheme.onSurface,
488 imageVector = Icons.Default.Add,
489 contentDescription =
490 stringResource(id = R.string.shortcut_helper_content_description_plus_icon),
491 modifier = Modifier.padding(vertical = 12.dp).size(24.dp).wrapContentSize(Alignment.Center),
492 )
493 }
494
495 @Composable
OutlinedInputFieldnull496 private fun OutlinedInputField(
497 content: @Composable (() -> Unit)?,
498 placeholder: @Composable () -> Unit,
499 trailingIcon: @Composable () -> Unit,
500 isError: Boolean,
501 modifier: Modifier = Modifier,
502 contentDescription: String,
503 ) {
504 OutlinedTextField(
505 value = "",
506 onValueChange = {},
507 placeholder = if (content == null) placeholder else null,
508 prefix = content,
509 singleLine = true,
510 modifier =
511 modifier.semantics(mergeDescendants = true) {
512 this.contentDescription = contentDescription
513 },
514 trailingIcon = trailingIcon,
515 colors =
516 OutlinedTextFieldDefaults.colors()
517 .copy(
518 focusedIndicatorColor = MaterialTheme.colorScheme.primary,
519 unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
520 errorIndicatorColor = MaterialTheme.colorScheme.error,
521 ),
522 shape = RoundedCornerShape(50.dp),
523 isError = isError,
524 )
525 }
526