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.app.AlertDialog
20 import android.content.DialogInterface
21 import androidx.annotation.VisibleForTesting
22 import androidx.compose.animation.Crossfade
23 import androidx.compose.animation.core.Animatable
24 import androidx.compose.animation.core.animateFloatAsState
25 import androidx.compose.animation.core.snap
26 import androidx.compose.animation.core.tween
27 import androidx.compose.foundation.Image
28 import androidx.compose.foundation.background
29 import androidx.compose.foundation.combinedClickable
30 import androidx.compose.foundation.gestures.awaitEachGesture
31 import androidx.compose.foundation.gestures.awaitFirstDown
32 import androidx.compose.foundation.gestures.detectTapGestures
33 import androidx.compose.foundation.layout.Arrangement
34 import androidx.compose.foundation.layout.Box
35 import androidx.compose.foundation.layout.BoxScope
36 import androidx.compose.foundation.layout.Column
37 import androidx.compose.foundation.layout.Row
38 import androidx.compose.foundation.layout.Spacer
39 import androidx.compose.foundation.layout.fillMaxHeight
40 import androidx.compose.foundation.layout.fillMaxWidth
41 import androidx.compose.foundation.layout.height
42 import androidx.compose.foundation.layout.imePadding
43 import androidx.compose.foundation.layout.padding
44 import androidx.compose.foundation.layout.size
45 import androidx.compose.foundation.layout.width
46 import androidx.compose.foundation.shape.RoundedCornerShape
47 import androidx.compose.material.icons.Icons
48 import androidx.compose.material.icons.filled.KeyboardArrowDown
49 import androidx.compose.material3.ButtonDefaults
50 import androidx.compose.material3.DropdownMenu
51 import androidx.compose.material3.DropdownMenuItem
52 import androidx.compose.material3.Icon
53 import androidx.compose.material3.MaterialTheme
54 import androidx.compose.material3.Text
55 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
56 import androidx.compose.runtime.Composable
57 import androidx.compose.runtime.DisposableEffect
58 import androidx.compose.runtime.LaunchedEffect
59 import androidx.compose.runtime.getValue
60 import androidx.compose.runtime.mutableStateOf
61 import androidx.compose.runtime.remember
62 import androidx.compose.runtime.setValue
63 import androidx.compose.ui.Alignment
64 import androidx.compose.ui.Modifier
65 import androidx.compose.ui.draw.clip
66 import androidx.compose.ui.draw.scale
67 import androidx.compose.ui.graphics.asImageBitmap
68 import androidx.compose.ui.graphics.graphicsLayer
69 import androidx.compose.ui.input.key.onKeyEvent
70 import androidx.compose.ui.input.pointer.PointerInputChange
71 import androidx.compose.ui.input.pointer.pointerInput
72 import androidx.compose.ui.platform.LocalContext
73 import androidx.compose.ui.platform.LocalDensity
74 import androidx.compose.ui.platform.LocalLayoutDirection
75 import androidx.compose.ui.platform.testTag
76 import androidx.compose.ui.res.stringResource
77 import androidx.compose.ui.semantics.Role
78 import androidx.compose.ui.semantics.role
79 import androidx.compose.ui.semantics.semantics
80 import androidx.compose.ui.text.style.TextOverflow
81 import androidx.compose.ui.unit.Dp
82 import androidx.compose.ui.unit.DpOffset
83 import androidx.compose.ui.unit.LayoutDirection
84 import androidx.compose.ui.unit.dp
85 import androidx.compose.ui.unit.sp
86 import androidx.compose.ui.unit.times
87 import androidx.lifecycle.compose.collectAsStateWithLifecycle
88 import com.android.compose.PlatformButton
89 import com.android.compose.animation.Easings
90 import com.android.compose.animation.scene.ContentScope
91 import com.android.compose.animation.scene.ElementKey
92 import com.android.compose.animation.scene.SceneKey
93 import com.android.compose.animation.scene.SceneTransitionLayout
94 import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
95 import com.android.compose.animation.scene.transitions
96 import com.android.compose.windowsizeclass.LocalWindowSizeClass
97 import com.android.systemui.bouncer.shared.model.BouncerActionButtonModel
98 import com.android.systemui.bouncer.ui.BouncerDialogFactory
99 import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
100 import com.android.systemui.bouncer.ui.viewmodel.BouncerMessageViewModel
101 import com.android.systemui.bouncer.ui.viewmodel.BouncerOverlayContentViewModel
102 import com.android.systemui.bouncer.ui.viewmodel.MessageViewModel
103 import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
104 import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
105 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
106 import com.android.systemui.common.shared.model.Text.Companion.loadText
107 import com.android.systemui.common.ui.compose.Icon
108 import com.android.systemui.compose.modifiers.sysuiResTag
109 import com.android.systemui.fold.ui.composable.foldPosture
110 import com.android.systemui.fold.ui.helper.FoldPosture
111 import com.android.systemui.res.R
112 import kotlin.math.abs
113 import kotlin.math.max
114 import kotlin.math.pow
115 import platform.test.motion.compose.values.MotionTestValueKey
116 import platform.test.motion.compose.values.MotionTestValues
117 import platform.test.motion.compose.values.motionTestValues
118
119 @Composable
120 fun BouncerContent(
121 viewModel: BouncerOverlayContentViewModel,
122 dialogFactory: BouncerDialogFactory,
123 modifier: Modifier = Modifier,
124 ) {
125 val isOneHandedModeSupported by viewModel.isOneHandedModeSupported.collectAsStateWithLifecycle()
126 val layout = calculateLayout(isOneHandedModeSupported = isOneHandedModeSupported)
127
128 BouncerContent(layout, viewModel, dialogFactory, modifier)
129 }
130
131 @Composable
132 @VisibleForTesting
BouncerContentnull133 fun BouncerContent(
134 layout: BouncerOverlayLayout,
135 viewModel: BouncerOverlayContentViewModel,
136 dialogFactory: BouncerDialogFactory,
137 modifier: Modifier,
138 ) {
139 val scale by viewModel.scale.collectAsStateWithLifecycle()
140 Box(
141 // Allows the content within each of the layouts to react to the appearance and
142 // disappearance of the IME, which is also known as the software keyboard.
143 //
144 // Despite the keyboard only being part of the password bouncer, adding it at this level is
145 // both necessary to properly handle the keyboard in all layouts and harmless in cases when
146 // the keyboard isn't used (like the PIN or pattern auth methods).
147 modifier = modifier.imePadding().onKeyEvent(viewModel::onKeyEvent).scale(scale)
148 ) {
149 when (layout) {
150 BouncerOverlayLayout.STANDARD_BOUNCER -> StandardLayout(viewModel = viewModel)
151 BouncerOverlayLayout.BESIDE_USER_SWITCHER ->
152 BesideUserSwitcherLayout(viewModel = viewModel)
153 BouncerOverlayLayout.BELOW_USER_SWITCHER ->
154 BelowUserSwitcherLayout(viewModel = viewModel)
155 BouncerOverlayLayout.SPLIT_BOUNCER -> SplitLayout(viewModel = viewModel)
156 }
157
158 Dialog(bouncerViewModel = viewModel, dialogFactory = dialogFactory)
159 }
160 }
161
162 /**
163 * Renders the contents of the actual bouncer UI, the area that takes user input to do an
164 * authentication attempt, including all messaging UI (directives, reasoning, errors, etc.).
165 */
166 @Composable
StandardLayoutnull167 private fun StandardLayout(
168 viewModel: BouncerOverlayContentViewModel,
169 modifier: Modifier = Modifier,
170 ) {
171 val isHeightExpanded =
172 LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Expanded
173
174 FoldAware(
175 modifier = modifier.padding(top = 92.dp, bottom = 48.dp),
176 viewModel = viewModel,
177 aboveFold = {
178 Column(
179 horizontalAlignment = Alignment.CenterHorizontally,
180 modifier = Modifier.fillMaxWidth(),
181 ) {
182 StatusMessage(viewModel = viewModel.message, modifier = Modifier)
183
184 OutputArea(
185 viewModel = viewModel,
186 modifier = Modifier.padding(top = if (isHeightExpanded) 96.dp else 64.dp),
187 )
188 }
189 },
190 belowFold = {
191 Column(
192 horizontalAlignment = Alignment.CenterHorizontally,
193 modifier = Modifier.fillMaxWidth(),
194 ) {
195 Box(modifier = Modifier.weight(1f)) {
196 InputArea(
197 viewModel = viewModel,
198 pinButtonRowVerticalSpacing = 12.dp,
199 centerPatternDotsVertically = false,
200 modifier = Modifier.align(Alignment.BottomCenter),
201 )
202 }
203
204 ActionArea(viewModel = viewModel, modifier = Modifier.padding(top = 48.dp))
205 }
206 },
207 )
208 }
209
210 /**
211 * Renders the bouncer UI in split mode, with half on one side and half on the other side, swappable
212 * by double-tapping on the side.
213 */
214 @Composable
SplitLayoutnull215 private fun SplitLayout(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
216 val authMethod by viewModel.authMethodViewModel.collectAsStateWithLifecycle()
217
218 Row(
219 modifier =
220 modifier
221 .fillMaxHeight()
222 .padding(
223 horizontal = 24.dp,
224 vertical = if (authMethod is PasswordBouncerViewModel) 24.dp else 48.dp,
225 )
226 ) {
227 // Left side (in left-to-right locales).
228 Box(modifier = Modifier.fillMaxHeight().weight(1f)) {
229 when (authMethod) {
230 is PinBouncerViewModel -> {
231 StatusMessage(
232 viewModel = viewModel.message,
233 modifier = Modifier.align(Alignment.TopCenter),
234 )
235 OutputArea(
236 viewModel = viewModel,
237 modifier =
238 Modifier.align(Alignment.Center).sysuiResTag("bouncer_text_entry"),
239 )
240
241 ActionArea(
242 viewModel = viewModel,
243 modifier = Modifier.align(Alignment.BottomCenter).padding(top = 48.dp),
244 )
245 }
246 is PatternBouncerViewModel -> {
247 StatusMessage(
248 viewModel = viewModel.message,
249 modifier = Modifier.align(Alignment.TopCenter),
250 )
251
252 ActionArea(
253 viewModel = viewModel,
254 modifier = Modifier.align(Alignment.BottomCenter).padding(vertical = 48.dp),
255 )
256 }
257 is PasswordBouncerViewModel -> {
258 ActionArea(
259 viewModel = viewModel,
260 modifier = Modifier.align(Alignment.BottomCenter),
261 )
262 }
263 else -> Unit
264 }
265 }
266
267 // Right side (in right-to-left locales).
268 Box(modifier = Modifier.fillMaxHeight().weight(1f)) {
269 when (authMethod) {
270 is PinBouncerViewModel,
271 is PatternBouncerViewModel -> {
272 InputArea(
273 viewModel = viewModel,
274 pinButtonRowVerticalSpacing = 8.dp,
275 centerPatternDotsVertically = true,
276 modifier = Modifier.align(Alignment.Center),
277 )
278 }
279 is PasswordBouncerViewModel -> {
280 Column(
281 horizontalAlignment = Alignment.CenterHorizontally,
282 modifier = Modifier.fillMaxWidth().align(Alignment.Center),
283 ) {
284 StatusMessage(viewModel = viewModel.message)
285 OutputArea(
286 viewModel = viewModel,
287 modifier =
288 Modifier.padding(top = 24.dp).sysuiResTag("bouncer_text_entry"),
289 )
290 }
291 }
292 else -> Unit
293 }
294 }
295 }
296 }
297
298 /**
299 * Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
300 * anywhere on the background to flip their positions.
301 */
302 @Composable
BesideUserSwitcherLayoutnull303 private fun BesideUserSwitcherLayout(
304 viewModel: BouncerOverlayContentViewModel,
305 modifier: Modifier = Modifier,
306 ) {
307 val isLeftToRight = LocalLayoutDirection.current == LayoutDirection.Ltr
308 val isInputPreferredOnLeftSide by
309 viewModel.isInputPreferredOnLeftSide.collectAsStateWithLifecycle()
310 // Swaps the order of user switcher and bouncer input area
311 // Default layout is assumed as user switcher followed by bouncer input area in the direction
312 // of layout.
313 val isSwapped = isLeftToRight == isInputPreferredOnLeftSide
314 val isHeightExpanded =
315 LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Expanded
316 val authMethod by viewModel.authMethodViewModel.collectAsStateWithLifecycle()
317
318 var swapAnimationEnd by remember { mutableStateOf(false) }
319
320 fun wasEventOnNonInputHalfOfScreen(x: Float, totalWidth: Int): Boolean {
321 // Default layout is assumed as user switcher followed by bouncer input area in
322 // the direction of layout. Swapped layout means that bouncer input area is first, followed
323 // by user switcher in the direction of layout.
324 val halfWidth = totalWidth / 2
325 return if (x > halfWidth) {
326 isLeftToRight && isSwapped
327 } else {
328 isLeftToRight && !isSwapped
329 }
330 }
331
332 Row(
333 modifier =
334 modifier
335 .pointerInput(isSwapped, isInputPreferredOnLeftSide) {
336 detectTapGestures(
337 onDoubleTap = { offset ->
338 // Depending on where the user double tapped, switch the elements such
339 // that the non-swapped element is closer to the side that was double
340 // tapped.
341 viewModel.onDoubleTap(
342 wasEventOnNonInputHalfOfScreen(offset.x, size.width)
343 )
344 }
345 )
346 }
347 .pointerInput(isSwapped, isInputPreferredOnLeftSide) {
348 awaitEachGesture {
349 val downEvent: PointerInputChange = awaitFirstDown()
350 viewModel.onDown(
351 wasEventOnNonInputHalfOfScreen(downEvent.position.x, size.width)
352 )
353 }
354 }
355 .testTag("BesideUserSwitcherLayout")
356 .motionTestValues {
357 swapAnimationEnd exportAs BouncerMotionTestKeys.swapAnimationEnd
358 }
359 .padding(
360 top = if (isHeightExpanded) 128.dp else 96.dp,
361 bottom = if (isHeightExpanded) 128.dp else 48.dp,
362 )
363 ) {
364 LaunchedEffect(isSwapped) { swapAnimationEnd = false }
365 val animatedOffset by
366 animateFloatAsState(
367 targetValue =
368 if (!isSwapped) {
369 // A non-swapped element has its natural placement so it's not offset.
370 0f
371 } else if (isLeftToRight) {
372 // A swapped element has its elements offset horizontally. In the case of
373 // LTR locales, this means pushing the element to the right, hence the
374 // positive number.
375 1f
376 } else {
377 // A swapped element has its elements offset horizontally. In the case of
378 // RTL locales, this means pushing the element to the left, hence the
379 // negative number.
380 -1f
381 },
382 label = "offset",
383 ) {
384 swapAnimationEnd = true
385 }
386
387 fun Modifier.swappable(inversed: Boolean = false): Modifier {
388 return graphicsLayer {
389 translationX =
390 size.width *
391 animatedOffset *
392 if (inversed) {
393 // A negative sign is used to make sure this is offset in the
394 // direction that's opposite to the direction that the user
395 // switcher is pushed in.
396 -1
397 } else {
398 1
399 }
400 alpha = animatedAlpha(animatedOffset)
401 }
402 .motionTestValues { animatedAlpha(animatedOffset) exportAs MotionTestValues.alpha }
403 }
404
405 UserSwitcher(viewModel = viewModel, modifier = Modifier.weight(1f).swappable())
406
407 FoldAware(
408 modifier = Modifier.weight(1f).swappable(inversed = true).testTag("FoldAware"),
409 viewModel = viewModel,
410 aboveFold = {
411 Column(
412 horizontalAlignment = Alignment.CenterHorizontally,
413 modifier = Modifier.fillMaxWidth(),
414 ) {
415 StatusMessage(viewModel = viewModel.message)
416 OutputArea(
417 viewModel = viewModel,
418 modifier = Modifier.padding(top = 24.dp).sysuiResTag("bouncer_text_entry"),
419 )
420 }
421 },
422 belowFold = {
423 Column(
424 horizontalAlignment = Alignment.CenterHorizontally,
425 modifier = Modifier.fillMaxWidth(),
426 ) {
427 val isOutputAreaVisible = authMethod !is PatternBouncerViewModel
428 // If there is an output area and the window is not tall enough, spacing needs
429 // to be added between the input and the output areas (otherwise the two get
430 // very squished together).
431 val addSpacingBetweenOutputAndInput = isOutputAreaVisible && !isHeightExpanded
432
433 Box(
434 modifier =
435 Modifier.weight(1f)
436 .padding(top = (if (addSpacingBetweenOutputAndInput) 24 else 0).dp)
437 ) {
438 InputArea(
439 viewModel = viewModel,
440 pinButtonRowVerticalSpacing = 12.dp,
441 centerPatternDotsVertically = true,
442 modifier = Modifier.align(Alignment.BottomCenter),
443 )
444 }
445
446 ActionArea(
447 viewModel = viewModel,
448 modifier = Modifier.padding(top = 48.dp).testTag("ActionArea"),
449 )
450 }
451 },
452 )
453 }
454 }
455
456 /** Arranges the bouncer contents and user switcher contents one on top of the other, vertically. */
457 @Composable
BelowUserSwitcherLayoutnull458 private fun BelowUserSwitcherLayout(
459 viewModel: BouncerOverlayContentViewModel,
460 modifier: Modifier = Modifier,
461 ) {
462 Column(modifier = modifier.padding(vertical = 128.dp)) {
463 UserSwitcher(viewModel = viewModel, modifier = Modifier.fillMaxWidth())
464
465 Spacer(Modifier.weight(1f))
466
467 Box(modifier = Modifier.fillMaxWidth()) {
468 Column(
469 horizontalAlignment = Alignment.CenterHorizontally,
470 modifier = Modifier.fillMaxWidth(),
471 ) {
472 StatusMessage(viewModel = viewModel.message)
473 OutputArea(viewModel = viewModel, modifier = Modifier.padding(top = 24.dp))
474
475 InputArea(
476 viewModel = viewModel,
477 pinButtonRowVerticalSpacing = 12.dp,
478 centerPatternDotsVertically = true,
479 modifier = Modifier.padding(top = 128.dp),
480 )
481
482 ActionArea(viewModel = viewModel, modifier = Modifier.padding(top = 48.dp))
483 }
484 }
485 }
486 }
487
488 @Composable
FoldAwarenull489 private fun FoldAware(
490 viewModel: BouncerOverlayContentViewModel,
491 aboveFold: @Composable BoxScope.() -> Unit,
492 belowFold: @Composable BoxScope.() -> Unit,
493 modifier: Modifier = Modifier,
494 ) {
495 val foldPosture: FoldPosture by foldPosture()
496 val isSplitAroundTheFoldRequired by viewModel.isFoldSplitRequired.collectAsStateWithLifecycle()
497 val isSplitAroundTheFold = foldPosture == FoldPosture.Tabletop && isSplitAroundTheFoldRequired
498 val currentSceneKey =
499 if (isSplitAroundTheFold) SceneKeys.SplitSceneKey else SceneKeys.ContiguousSceneKey
500
501 val state = rememberMutableSceneTransitionLayoutState(currentSceneKey, SceneTransitions)
502
503 // Update state whenever currentSceneKey has changed.
504 LaunchedEffect(state, currentSceneKey) {
505 if (currentSceneKey != state.transitionState.currentScene) {
506 state.setTargetScene(currentSceneKey, animationScope = this)
507 }
508 }
509
510 SceneTransitionLayout(state, modifier = modifier) {
511 scene(SceneKeys.ContiguousSceneKey) {
512 FoldableScene(aboveFold = aboveFold, belowFold = belowFold, isSplit = false)
513 }
514
515 scene(SceneKeys.SplitSceneKey) {
516 FoldableScene(aboveFold = aboveFold, belowFold = belowFold, isSplit = true)
517 }
518 }
519 }
520
521 @Composable
FoldableScenenull522 private fun ContentScope.FoldableScene(
523 aboveFold: @Composable BoxScope.() -> Unit,
524 belowFold: @Composable BoxScope.() -> Unit,
525 isSplit: Boolean,
526 modifier: Modifier = Modifier,
527 ) {
528 val splitRatio =
529 LocalContext.current.resources.getFloat(
530 R.dimen.motion_layout_half_fold_bouncer_height_ratio
531 )
532
533 Column(modifier = modifier.fillMaxHeight()) {
534 // Content above the fold, when split on a foldable device in a "table top" posture:
535 Box(
536 modifier =
537 Modifier.element(SceneElements.AboveFold)
538 .then(
539 if (isSplit) {
540 Modifier.weight(splitRatio)
541 } else {
542 Modifier
543 }
544 )
545 ) {
546 aboveFold()
547 }
548
549 // Content below the fold, when split on a foldable device in a "table top" posture:
550 Box(
551 modifier =
552 Modifier.element(SceneElements.BelowFold)
553 .weight(
554 if (isSplit) {
555 1 - splitRatio
556 } else {
557 1f
558 }
559 )
560 ) {
561 belowFold()
562 }
563 }
564 }
565
566 @Composable
StatusMessagenull567 private fun StatusMessage(viewModel: BouncerMessageViewModel, modifier: Modifier = Modifier) {
568 val message: MessageViewModel? by viewModel.message.collectAsStateWithLifecycle()
569
570 DisposableEffect(Unit) {
571 viewModel.onShown()
572 onDispose {}
573 }
574
575 Crossfade(
576 targetState = message,
577 label = "Bouncer message",
578 animationSpec = if (message?.isUpdateAnimated == true) tween() else snap(),
579 modifier = modifier.fillMaxWidth(),
580 ) { msg ->
581 Column(
582 horizontalAlignment = Alignment.CenterHorizontally,
583 modifier = Modifier.fillMaxWidth(),
584 ) {
585 msg?.let {
586 Text(
587 text = it.text,
588 color = MaterialTheme.colorScheme.onSurface,
589 fontSize = 18.sp,
590 lineHeight = 24.sp,
591 overflow = TextOverflow.Ellipsis,
592 )
593 Spacer(modifier = Modifier.size(10.dp))
594 Text(
595 text = it.secondaryText ?: "",
596 color = MaterialTheme.colorScheme.onSurface,
597 fontSize = 14.sp,
598 lineHeight = 20.sp,
599 overflow = TextOverflow.Ellipsis,
600 maxLines = 2,
601 )
602 }
603 }
604 }
605 }
606
607 /**
608 * Renders the user output area, where the user sees what they entered.
609 *
610 * For example, this can be the PIN shapes or password text field.
611 */
612 @Composable
OutputAreanull613 private fun OutputArea(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
614 val authMethodViewModel: AuthMethodBouncerViewModel? by
615 viewModel.authMethodViewModel.collectAsStateWithLifecycle()
616 when (val nonNullViewModel = authMethodViewModel) {
617 is PinBouncerViewModel ->
618 PinInputDisplay(
619 viewModel = nonNullViewModel,
620 modifier = modifier.sysuiResTag("bouncer_text_entry"),
621 )
622 is PasswordBouncerViewModel ->
623 PasswordBouncer(
624 viewModel = nonNullViewModel,
625 modifier = modifier.sysuiResTag("bouncer_text_entry"),
626 )
627 else -> Unit
628 }
629 }
630
631 /**
632 * Renders the user input area, where the user enters their credentials.
633 *
634 * For example, this can be the pattern input area or the PIN pad.
635 */
636 @Composable
InputAreanull637 private fun InputArea(
638 viewModel: BouncerOverlayContentViewModel,
639 pinButtonRowVerticalSpacing: Dp,
640 centerPatternDotsVertically: Boolean,
641 modifier: Modifier = Modifier,
642 ) {
643 val authMethodViewModel: AuthMethodBouncerViewModel? by
644 viewModel.authMethodViewModel.collectAsStateWithLifecycle()
645
646 when (val nonNullViewModel = authMethodViewModel) {
647 is PinBouncerViewModel -> {
648 PinPad(
649 viewModel = nonNullViewModel,
650 verticalSpacing = pinButtonRowVerticalSpacing,
651 modifier = modifier,
652 )
653 }
654 is PatternBouncerViewModel -> {
655 PatternBouncer(
656 viewModel = nonNullViewModel,
657 centerDotsVertically = centerPatternDotsVertically,
658 modifier = modifier,
659 )
660 }
661 else -> Unit
662 }
663 }
664
665 @Composable
ActionAreanull666 private fun ActionArea(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
667 val actionButton: BouncerActionButtonModel? by
668 viewModel.actionButton.collectAsStateWithLifecycle()
669 val appearFadeInAnimatable = remember { Animatable(0f) }
670 val appearMoveAnimatable = remember { Animatable(0f) }
671 val appearAnimationInitialOffset = with(LocalDensity.current) { 80.dp.toPx() }
672
673 actionButton?.let { actionButtonModel ->
674 LaunchedEffect(Unit) {
675 appearFadeInAnimatable.animateTo(
676 targetValue = 1f,
677 animationSpec =
678 tween(
679 durationMillis = 450,
680 delayMillis = 133,
681 easing = Easings.LegacyDecelerate,
682 ),
683 )
684 }
685 LaunchedEffect(Unit) {
686 appearMoveAnimatable.animateTo(
687 targetValue = 1f,
688 animationSpec =
689 tween(
690 durationMillis = 450,
691 delayMillis = 133,
692 easing = Easings.StandardDecelerate,
693 ),
694 )
695 }
696
697 Box(
698 modifier =
699 modifier
700 .graphicsLayer {
701 // Translate the button up from an initially pushed-down position:
702 translationY =
703 (1 - appearMoveAnimatable.value) * appearAnimationInitialOffset
704 // Fade the button in:
705 alpha = appearFadeInAnimatable.value
706 }
707 .height(56.dp)
708 .clip(ButtonDefaults.shape)
709 .background(color = MaterialTheme.colorScheme.tertiaryContainer)
710 .semantics { role = Role.Button }
711 .combinedClickable(
712 onClick = { actionButton?.let { viewModel.onActionButtonClicked(it) } },
713 onLongClick = {
714 actionButton?.let { viewModel.onActionButtonLongClicked(it) }
715 },
716 )
717 ) {
718 Text(
719 text = stringResource(id = actionButtonModel.labelResId),
720 style = MaterialTheme.typography.bodyMedium,
721 color = MaterialTheme.colorScheme.onTertiaryContainer,
722 modifier = Modifier.align(Alignment.Center).padding(ButtonDefaults.ContentPadding),
723 )
724 }
725 }
726 }
727
728 @Composable
Dialognull729 private fun Dialog(
730 bouncerViewModel: BouncerOverlayContentViewModel,
731 dialogFactory: BouncerDialogFactory,
732 ) {
733 val dialogViewModel by bouncerViewModel.dialogViewModel.collectAsStateWithLifecycle()
734 var dialog: AlertDialog? by remember { mutableStateOf(null) }
735
736 dialogViewModel?.let { viewModel ->
737 if (dialog == null) {
738 dialog = dialogFactory()
739 }
740 dialog?.apply {
741 setMessage(viewModel.text)
742 setButton(DialogInterface.BUTTON_NEUTRAL, context.getString(R.string.ok)) { _, _ ->
743 viewModel.onDismiss()
744 }
745 setCancelable(false)
746 setCanceledOnTouchOutside(false)
747 show()
748 }
749 }
750 ?: {
751 dialog?.dismiss()
752 dialog = null
753 }
754 }
755
756 /** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
757 @Composable
UserSwitchernull758 private fun UserSwitcher(viewModel: BouncerOverlayContentViewModel, modifier: Modifier = Modifier) {
759 val isUserSwitcherVisible by viewModel.isUserSwitcherVisible.collectAsStateWithLifecycle()
760 if (!isUserSwitcherVisible) {
761 // Take up the same space as the user switcher normally would, but with nothing inside it.
762 Box(modifier = modifier)
763 return
764 }
765
766 val selectedUserImage by viewModel.selectedUserImage.collectAsStateWithLifecycle(null)
767 val dropdownItems by viewModel.userSwitcherDropdown.collectAsStateWithLifecycle(emptyList())
768
769 Column(
770 horizontalAlignment = Alignment.CenterHorizontally,
771 verticalArrangement = Arrangement.Center,
772 modifier = modifier.sysuiResTag("UserSwitcher"),
773 ) {
774 selectedUserImage?.let {
775 Image(
776 bitmap = it.asImageBitmap(),
777 contentDescription = null,
778 modifier = Modifier.size(SelectedUserImageSize).sysuiResTag("user_icon"),
779 )
780 }
781
782 val (isDropdownExpanded, setDropdownExpanded) = remember { mutableStateOf(false) }
783
784 dropdownItems.firstOrNull()?.let { firstDropdownItem ->
785 Spacer(modifier = Modifier.height(40.dp))
786
787 Box {
788 PlatformButton(
789 modifier =
790 Modifier
791 // Remove the built-in padding applied inside PlatformButton:
792 .padding(vertical = 0.dp)
793 .width(UserSwitcherDropdownWidth)
794 .height(UserSwitcherDropdownHeight),
795 colors =
796 ButtonDefaults.buttonColors(
797 containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
798 contentColor = MaterialTheme.colorScheme.onSurface,
799 ),
800 onClick = { setDropdownExpanded(!isDropdownExpanded) },
801 ) {
802 val context = LocalContext.current
803 Text(
804 text = checkNotNull(firstDropdownItem.text.loadText(context)),
805 style = MaterialTheme.typography.headlineSmall,
806 maxLines = 1,
807 overflow = TextOverflow.Ellipsis,
808 )
809
810 Spacer(modifier = Modifier.weight(1f))
811
812 Icon(
813 imageVector = Icons.Default.KeyboardArrowDown,
814 contentDescription = null,
815 modifier = Modifier.size(32.dp).sysuiResTag("user_switcher_anchor"),
816 )
817 }
818
819 UserSwitcherDropdownMenu(
820 isExpanded = isDropdownExpanded,
821 items = dropdownItems,
822 onDismissed = { setDropdownExpanded(false) },
823 )
824 }
825 }
826 }
827 }
828
829 /**
830 * Renders the dropdown menu that displays the actual users and/or user actions that can be
831 * selected.
832 */
833 @Composable
UserSwitcherDropdownMenunull834 private fun UserSwitcherDropdownMenu(
835 isExpanded: Boolean,
836 items: List<BouncerOverlayContentViewModel.UserSwitcherDropdownItemViewModel>,
837 onDismissed: () -> Unit,
838 ) {
839 val context = LocalContext.current
840
841 // TODO(b/303071855): once the FR is fixed, remove this composition local override.
842 MaterialTheme(
843 colorScheme =
844 MaterialTheme.colorScheme.copy(
845 surface = MaterialTheme.colorScheme.surfaceContainerHighest
846 ),
847 shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(28.dp)),
848 ) {
849 DropdownMenu(
850 expanded = isExpanded,
851 onDismissRequest = onDismissed,
852 offset = DpOffset(x = 0.dp, y = -UserSwitcherDropdownHeight),
853 modifier = Modifier.width(UserSwitcherDropdownWidth).sysuiResTag("user_list_dropdown"),
854 ) {
855 items.forEach { userSwitcherDropdownItem ->
856 DropdownMenuItem(
857 modifier = Modifier.sysuiResTag("user_switcher_item"),
858 leadingIcon = {
859 Icon(
860 icon = userSwitcherDropdownItem.icon,
861 tint = MaterialTheme.colorScheme.primary,
862 modifier = Modifier.size(28.dp),
863 )
864 },
865 text = {
866 Text(
867 text = checkNotNull(userSwitcherDropdownItem.text.loadText(context)),
868 style = MaterialTheme.typography.bodyLarge,
869 color = MaterialTheme.colorScheme.onSurface,
870 )
871 },
872 onClick = {
873 onDismissed()
874 userSwitcherDropdownItem.onClick()
875 },
876 )
877 }
878 }
879 }
880 }
881
882 /**
883 * Calculates an alpha for the user switcher and bouncer such that it's at `1` when the offset of
884 * the two reaches a stopping point but `0` in the middle of the transition.
885 */
animatedAlphanull886 private fun animatedAlpha(offset: Float): Float {
887 // Describes a curve that is made of two parabolic U-shaped curves mirrored horizontally around
888 // the y-axis. The U on the left runs between x = -1 and x = 0 while the U on the right runs
889 // between x = 0 and x = 1.
890 //
891 // The minimum values of the curves are at -0.5 and +0.5.
892 //
893 // Both U curves are vertically scaled such that they reach the points (-1, 1) and (1, 1).
894 //
895 // Breaking it down, it's y = a×(|x|-m)²+b, where:
896 // x: the offset
897 // y: the alpha
898 // m: x-axis center of the parabolic curves, where the minima are.
899 // b: y-axis offset to apply to the entire curve so the animation spends more time with alpha =
900 // 0.
901 // a: amplitude to scale the parabolic curves to reach y = 1 at x = -1, x = 0, and x = +1.
902 val m = 0.5f
903 val b = -0.25
904 val a = (1 - b) / m.pow(2)
905
906 return max(0f, (a * (abs(offset) - m).pow(2) + b).toFloat())
907 }
908
909 private val SelectedUserImageSize = 190.dp
910 private val UserSwitcherDropdownWidth = SelectedUserImageSize + 2 * 29.dp
911 private val UserSwitcherDropdownHeight = 60.dp
912
913 private object SceneKeys {
914 val ContiguousSceneKey = SceneKey("default")
915 val SplitSceneKey = SceneKey("split")
916 }
917
918 private object SceneElements {
919 val AboveFold = ElementKey("above_fold")
920 val BelowFold = ElementKey("below_fold")
921 }
922
<lambda>null923 private val SceneTransitions = transitions {
924 from(SceneKeys.ContiguousSceneKey, to = SceneKeys.SplitSceneKey) { spec = tween() }
925 }
926
927 @VisibleForTesting
928 object BouncerMotionTestKeys {
929 val swapAnimationEnd = MotionTestValueKey<Boolean>("swapAnimationEnd")
930 }
931