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