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