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