• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 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.bouncer.ui.composable
18 
19 import android.view.MotionEvent
20 import android.view.View
21 import androidx.compose.animation.animateColorAsState
22 import androidx.compose.animation.core.Animatable
23 import androidx.compose.animation.core.AnimationSpec
24 import androidx.compose.animation.core.AnimationVector1D
25 import androidx.compose.animation.core.animateDpAsState
26 import androidx.compose.animation.core.animateFloatAsState
27 import androidx.compose.animation.core.tween
28 import androidx.compose.foundation.LocalIndication
29 import androidx.compose.foundation.combinedClickable
30 import androidx.compose.foundation.focusable
31 import androidx.compose.foundation.interaction.MutableInteractionSource
32 import androidx.compose.foundation.interaction.collectIsPressedAsState
33 import androidx.compose.foundation.layout.Box
34 import androidx.compose.foundation.layout.aspectRatio
35 import androidx.compose.foundation.layout.sizeIn
36 import androidx.compose.foundation.shape.CircleShape
37 import androidx.compose.material3.MaterialTheme
38 import androidx.compose.material3.Text
39 import androidx.compose.runtime.Composable
40 import androidx.compose.runtime.DisposableEffect
41 import androidx.compose.runtime.LaunchedEffect
42 import androidx.compose.runtime.getValue
43 import androidx.compose.runtime.remember
44 import androidx.compose.ui.Alignment
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.draw.clip
47 import androidx.compose.ui.draw.drawBehind
48 import androidx.compose.ui.focus.FocusRequester
49 import androidx.compose.ui.focus.focusRequester
50 import androidx.compose.ui.geometry.CornerRadius
51 import androidx.compose.ui.graphics.Color
52 import androidx.compose.ui.graphics.graphicsLayer
53 import androidx.compose.ui.input.pointer.pointerInteropFilter
54 import androidx.compose.ui.platform.LocalView
55 import androidx.compose.ui.unit.Dp
56 import androidx.compose.ui.unit.dp
57 import androidx.lifecycle.compose.collectAsStateWithLifecycle
58 import com.android.compose.animation.Easings
59 import com.android.compose.grid.VerticalGrid
60 import com.android.compose.modifiers.thenIf
61 import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance
62 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
63 import com.android.systemui.common.shared.model.ContentDescription
64 import com.android.systemui.common.shared.model.Icon
65 import com.android.systemui.common.ui.compose.Icon
66 import com.android.systemui.compose.modifiers.sysuiResTag
67 import com.android.systemui.res.R
68 import kotlin.time.Duration.Companion.milliseconds
69 import kotlin.time.DurationUnit
70 import kotlinx.coroutines.coroutineScope
71 import kotlinx.coroutines.launch
72 
73 /** Renders the PIN button pad. */
74 @Composable
75 fun PinPad(viewModel: PinBouncerViewModel, verticalSpacing: Dp, modifier: Modifier = Modifier) {
76     DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }
77 
78     val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsStateWithLifecycle()
79     val backspaceButtonAppearance by
80         viewModel.backspaceButtonAppearance.collectAsStateWithLifecycle()
81     val confirmButtonAppearance by viewModel.confirmButtonAppearance.collectAsStateWithLifecycle()
82     val animateFailure: Boolean by viewModel.animateFailure.collectAsStateWithLifecycle()
83     val isDigitButtonAnimationEnabled: Boolean by
84         viewModel.isDigitButtonAnimationEnabled.collectAsStateWithLifecycle()
85 
86     val buttonScaleAnimatables = remember { List(12) { Animatable(1f) } }
87     LaunchedEffect(animateFailure) {
88         // Show the failure animation if the user entered the wrong input.
89         if (animateFailure) {
90             showFailureAnimation(buttonScaleAnimatables)
91             viewModel.onFailureAnimationShown()
92         }
93     }
94 
95     // set the focus, so adb can send the key events for testing.
96     val focusRequester = FocusRequester()
97     LaunchedEffect(Unit) { focusRequester.requestFocus() }
98 
99     VerticalGrid(
100         columns = columns,
101         verticalSpacing = verticalSpacing,
102         horizontalSpacing = calculateHorizontalSpacingBetweenColumns(gridWidth = 300.dp),
103         modifier = modifier.focusRequester(focusRequester).sysuiResTag("pin_pad_grid"),
104     ) {
105         repeat(9) { index ->
106             DigitButton(
107                 digit = index + 1,
108                 isInputEnabled = isInputEnabled,
109                 onClicked = viewModel::onPinButtonClicked,
110                 scaling = buttonScaleAnimatables[index]::value,
111                 isAnimationEnabled = isDigitButtonAnimationEnabled,
112                 onPointerDown = viewModel::onDigitButtonDown,
113             )
114         }
115 
116         ActionButton(
117             icon =
118                 Icon.Resource(
119                     res = R.drawable.ic_backspace_24dp,
120                     contentDescription =
121                         ContentDescription.Resource(R.string.keyboardview_keycode_delete),
122                 ),
123             isInputEnabled = isInputEnabled,
124             onClicked = viewModel::onBackspaceButtonClicked,
125             onPointerDown = viewModel::onBackspaceButtonPressed,
126             onLongPressed = viewModel::onBackspaceButtonLongPressed,
127             appearance = backspaceButtonAppearance,
128             scaling = buttonScaleAnimatables[9]::value,
129             elementId = "delete_button",
130         )
131 
132         DigitButton(
133             digit = 0,
134             isInputEnabled = isInputEnabled,
135             onClicked = viewModel::onPinButtonClicked,
136             scaling = buttonScaleAnimatables[10]::value,
137             isAnimationEnabled = isDigitButtonAnimationEnabled,
138             onPointerDown = viewModel::onDigitButtonDown,
139         )
140 
141         ActionButton(
142             icon =
143                 Icon.Resource(
144                     res = R.drawable.ic_keyboard_tab_36dp,
145                     contentDescription =
146                         ContentDescription.Resource(R.string.keyboardview_keycode_enter),
147                 ),
148             isInputEnabled = isInputEnabled,
149             onClicked = viewModel::onAuthenticateButtonClicked,
150             appearance = confirmButtonAppearance,
151             scaling = buttonScaleAnimatables[11]::value,
152             elementId = "key_enter",
153         )
154     }
155 }
156 
157 @Composable
DigitButtonnull158 private fun DigitButton(
159     digit: Int,
160     isInputEnabled: Boolean,
161     onClicked: (Int) -> Unit,
162     onPointerDown: (View?) -> Unit,
163     scaling: () -> Float,
164     isAnimationEnabled: Boolean,
165 ) {
166     PinPadButton(
167         onClicked = { onClicked(digit) },
168         isEnabled = isInputEnabled,
169         backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
170         foregroundColor = MaterialTheme.colorScheme.onSurfaceVariant,
171         isAnimationEnabled = isAnimationEnabled,
172         onPointerDown = onPointerDown,
173         modifier =
174             Modifier.graphicsLayer {
175                 val scale = if (isAnimationEnabled) scaling() else 1f
176                 scaleX = scale
177                 scaleY = scale
178             },
179     ) { contentColor ->
180         // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes
181         // it into Text, use that here, to animate more efficiently.
182         Text(
183             text = digit.toString(),
184             style = MaterialTheme.typography.headlineLarge,
185             color = contentColor(),
186         )
187     }
188 }
189 
190 @Composable
ActionButtonnull191 private fun ActionButton(
192     icon: Icon,
193     isInputEnabled: Boolean,
194     onClicked: () -> Unit,
195     elementId: String,
196     onLongPressed: (() -> Unit)? = null,
197     onPointerDown: ((View?) -> Unit)? = null,
198     appearance: ActionButtonAppearance,
199     scaling: () -> Float,
200 ) {
201     val isHidden = appearance == ActionButtonAppearance.Hidden
202     val hiddenAlpha by animateFloatAsState(if (isHidden) 0f else 1f, label = "Action button alpha")
203 
204     val foregroundColor =
205         when (appearance) {
206             ActionButtonAppearance.Shown -> MaterialTheme.colorScheme.onSecondaryContainer
207             else -> MaterialTheme.colorScheme.onSurface
208         }
209     val backgroundColor =
210         when (appearance) {
211             ActionButtonAppearance.Shown -> MaterialTheme.colorScheme.secondaryContainer
212             else -> MaterialTheme.colorScheme.surface
213         }
214 
215     PinPadButton(
216         onClicked = onClicked,
217         onLongPressed = onLongPressed,
218         isEnabled = isInputEnabled && !isHidden,
219         backgroundColor = backgroundColor,
220         foregroundColor = foregroundColor,
221         isAnimationEnabled = true,
222         elementId = elementId,
223         onPointerDown = onPointerDown,
224         modifier =
225             Modifier.graphicsLayer {
226                 alpha = hiddenAlpha
227                 val scale = scaling()
228                 scaleX = scale
229                 scaleY = scale
230             },
231     ) { contentColor ->
232         Icon(icon = icon, tint = contentColor())
233     }
234 }
235 
236 @Composable
PinPadButtonnull237 private fun PinPadButton(
238     onClicked: () -> Unit,
239     isEnabled: Boolean,
240     backgroundColor: Color,
241     foregroundColor: Color,
242     isAnimationEnabled: Boolean,
243     modifier: Modifier = Modifier,
244     elementId: String? = null,
245     onLongPressed: (() -> Unit)? = null,
246     onPointerDown: ((View?) -> Unit)? = null,
247     content: @Composable (contentColor: () -> Color) -> Unit,
248 ) {
249     val interactionSource = remember { MutableInteractionSource() }
250     val isPressed by interactionSource.collectIsPressedAsState()
251     val indication = LocalIndication.current.takeUnless { isPressed }
252     val view = LocalView.current
253 
254     // Pin button animation specification is asymmetric: fast animation to the pressed state, and a
255     // slow animation upon release. Note that isPressed is guaranteed to be true for at least the
256     // press animation duration (see below in detectTapGestures).
257     val animEasing = if (isPressed) pinButtonPressedEasing else pinButtonReleasedEasing
258     val animDurationMillis =
259         (if (isPressed) pinButtonPressedDuration else pinButtonReleasedDuration).toInt(
260             DurationUnit.MILLISECONDS
261         )
262 
263     val cornerRadius: Dp by
264         animateDpAsState(
265             if (isAnimationEnabled && isPressed) 24.dp else pinButtonMaxSize / 2,
266             label = "PinButton round corners",
267             animationSpec = tween(animDurationMillis, easing = animEasing),
268         )
269     val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing)
270     val containerColor: Color by
271         animateColorAsState(
272             when {
273                 isAnimationEnabled && isPressed -> MaterialTheme.colorScheme.primary
274                 else -> backgroundColor
275             },
276             label = "Pin button container color",
277             animationSpec = colorAnimationSpec,
278         )
279     val contentColor =
280         animateColorAsState(
281             when {
282                 isAnimationEnabled && isPressed -> MaterialTheme.colorScheme.onPrimary
283                 else -> foregroundColor
284             },
285             label = "Pin button container color",
286             animationSpec = colorAnimationSpec,
287         )
288 
289     Box(
290         contentAlignment = Alignment.Center,
291         modifier =
292             modifier
293                 .focusRequester(FocusRequester.Default)
294                 .focusable()
295                 .sizeIn(maxWidth = pinButtonMaxSize, maxHeight = pinButtonMaxSize)
296                 .aspectRatio(1f)
297                 .drawBehind {
298                     drawRoundRect(
299                         color = containerColor,
300                         cornerRadius = CornerRadius(cornerRadius.toPx()),
301                     )
302                 }
303                 .clip(CircleShape)
304                 .thenIf(isEnabled) {
305                     Modifier.combinedClickable(
306                             interactionSource = interactionSource,
307                             indication = indication,
308                             onClick = onClicked,
309                             onLongClick = onLongPressed,
310                         )
311                         .pointerInteropFilter { motionEvent ->
312                             if (motionEvent.action == MotionEvent.ACTION_DOWN) {
313                                 onPointerDown?.let { it(view) }
314                             }
315                             false
316                         }
317                 }
318                 .thenIf(elementId != null) { Modifier.sysuiResTag(elementId!!) },
319     ) {
320         content(contentColor::value)
321     }
322 }
323 
showFailureAnimationnull324 private suspend fun showFailureAnimation(
325     buttonScaleAnimatables: List<Animatable<Float, AnimationVector1D>>
326 ) {
327     coroutineScope {
328         buttonScaleAnimatables.forEachIndexed { index, animatable ->
329             launch {
330                 animatable.animateTo(
331                     targetValue = pinButtonErrorShrinkFactor,
332                     animationSpec =
333                         tween(
334                             durationMillis = pinButtonErrorShrinkMs,
335                             delayMillis = index * pinButtonErrorStaggerDelayMs,
336                             easing = Easings.Linear,
337                         ),
338                 )
339 
340                 animatable.animateTo(
341                     targetValue = 1f,
342                     animationSpec =
343                         tween(durationMillis = pinButtonErrorRevertMs, easing = Easings.Legacy),
344                 )
345             }
346         }
347     }
348 }
349 
350 /** Returns the amount of horizontal spacing between columns, in dips. */
calculateHorizontalSpacingBetweenColumnsnull351 private fun calculateHorizontalSpacingBetweenColumns(gridWidth: Dp): Dp {
352     return (gridWidth - (pinButtonMaxSize * columns)) / (columns - 1)
353 }
354 
355 /** Number of columns in the PIN pad grid. */
356 private const val columns = 3
357 /** Maximum size (width and height) of each PIN pad button. */
358 private val pinButtonMaxSize = 84.dp
359 /** Scale factor to apply to buttons when animating the "error" animation on them. */
360 private val pinButtonErrorShrinkFactor = 67.dp / pinButtonMaxSize
361 /** Animation duration of the "shrink" phase of the error animation, on each PIN pad button. */
362 private const val pinButtonErrorShrinkMs = 50
363 /** Amount of time to wait between application of the "error" animation to each row of buttons. */
364 private const val pinButtonErrorStaggerDelayMs = 33
365 /** Animation duration of the "revert" phase of the error animation, on each PIN pad button. */
366 private const val pinButtonErrorRevertMs = 617
367 
368 // Pin button motion spec: http://shortn/_9TTIG6SoEa
369 private val pinButtonPressedDuration = 100.milliseconds
370 private val pinButtonPressedEasing = Easings.Linear
371 private val pinButtonHoldTime = 33.milliseconds
372 private val pinButtonReleasedDuration = 420.milliseconds
373 private val pinButtonReleasedEasing = Easings.Standard
374