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