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