Skip to content
Snippets Groups Projects
Commit a826a901 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Bouncer scene large screen support.

There are three different layouts in bouncer scene now:
- Large screens, landscape orientation: side-by-side layout where one
  side is the bouncer contents (where the user enters PIN, password, or
  pattern) and the other is the user switcher. Double tapping on the
  side opposite of the bouncer contents switches the two sides to bring
  the bouncer contents closer to the side that was double tapped.
- Large screens, portrait orientation: stacked layout where the user
  switcher is on top and the bouncer contents are below.
- Non-large screens: just the bouncer contents, just like before.

In this CL, the user switcher is just a placeholder. Will add it in
followup CLs.

Bug: 299343639
Test: please see screenshots and video on the associated bug.
Change-Id: I975e63c2b94ddf006836159befb3242187bbd40b
parent 6b577ee4
No related branches found
No related tags found
No related merge requests found
......@@ -14,40 +14,49 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.systemui.bouncer.ui.composable
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.SceneScope
import com.android.compose.windowsizeclass.LocalWindowSizeClass
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
......@@ -103,99 +112,234 @@ private fun SceneScope.BouncerScene(
dialogFactory: BouncerSceneDialogFactory,
modifier: Modifier = Modifier,
) {
val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
val authMethodViewModel: AuthMethodBouncerViewModel? by
viewModel.authMethodViewModel.collectAsState()
val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
var dialog: Dialog? by remember { mutableStateOf(null) }
val backgroundColor = MaterialTheme.colorScheme.surface
val windowSizeClass = LocalWindowSizeClass.current
Box(modifier) {
Canvas(Modifier.element(Bouncer.Elements.Background).fillMaxSize()) {
drawRect(color = backgroundColor)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(60.dp),
modifier =
Modifier.element(Bouncer.Elements.Content)
.fillMaxSize()
.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp)
) {
Crossfade(
targetState = message,
label = "Bouncer message",
animationSpec = if (message.isUpdateAnimated) tween() else snap(),
) { message ->
Text(
text = message.text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
val childModifier = Modifier.element(Bouncer.Elements.Content).fillMaxSize()
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Expanded ->
SideBySide(
viewModel = viewModel,
dialogFactory = dialogFactory,
modifier = childModifier,
)
}
WindowWidthSizeClass.Medium ->
Stacked(
viewModel = viewModel,
dialogFactory = dialogFactory,
modifier = childModifier,
)
else ->
Bouncer(
viewModel = viewModel,
dialogFactory = dialogFactory,
modifier = childModifier,
)
}
}
}
Box(Modifier.weight(1f)) {
when (val nonNullViewModel = authMethodViewModel) {
is PinBouncerViewModel ->
PinBouncer(
viewModel = nonNullViewModel,
modifier = Modifier.align(Alignment.Center),
)
is PasswordBouncerViewModel ->
PasswordBouncer(
viewModel = nonNullViewModel,
modifier = Modifier.align(Alignment.Center),
)
is PatternBouncerViewModel ->
PatternBouncer(
viewModel = nonNullViewModel,
modifier =
Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
.align(Alignment.BottomCenter),
)
else -> Unit
}
}
/**
* Renders the contents of the actual bouncer UI, the area that takes user input to do an
* authentication attempt, including all messaging UI (directives, reasoning, errors, etc.).
*/
@Composable
private fun Bouncer(
viewModel: BouncerViewModel,
dialogFactory: BouncerSceneDialogFactory,
modifier: Modifier = Modifier,
) {
val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
val authMethodViewModel: AuthMethodBouncerViewModel? by
viewModel.authMethodViewModel.collectAsState()
val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
var dialog: Dialog? by remember { mutableStateOf(null) }
Button(
onClick = viewModel::onEmergencyServicesButtonClicked,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
) {
Text(
text = stringResource(com.android.internal.R.string.lockscreen_emergency_call),
style = MaterialTheme.typography.bodyMedium,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(60.dp),
modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp)
) {
Crossfade(
targetState = message,
label = "Bouncer message",
animationSpec = if (message.isUpdateAnimated) tween() else snap(),
) { message ->
Text(
text = message.text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
)
}
Box(Modifier.weight(1f)) {
when (val nonNullViewModel = authMethodViewModel) {
is PinBouncerViewModel ->
PinBouncer(
viewModel = nonNullViewModel,
modifier = Modifier.align(Alignment.Center),
)
is PasswordBouncerViewModel ->
PasswordBouncer(
viewModel = nonNullViewModel,
modifier = Modifier.align(Alignment.Center),
)
is PatternBouncerViewModel ->
PatternBouncer(
viewModel = nonNullViewModel,
modifier =
Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
.align(Alignment.BottomCenter),
)
else -> Unit
}
}
Button(
onClick = viewModel::onEmergencyServicesButtonClicked,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
) {
Text(
text = stringResource(com.android.internal.R.string.lockscreen_emergency_call),
style = MaterialTheme.typography.bodyMedium,
)
}
if (dialogMessage != null) {
if (dialog == null) {
dialog =
dialogFactory().apply {
setMessage(dialogMessage)
setButton(
DialogInterface.BUTTON_NEUTRAL,
context.getString(R.string.ok),
) { _, _ ->
viewModel.onThrottlingDialogDismissed()
}
setCancelable(false)
setCanceledOnTouchOutside(false)
show()
if (dialogMessage != null) {
if (dialog == null) {
dialog =
dialogFactory().apply {
setMessage(dialogMessage)
setButton(
DialogInterface.BUTTON_NEUTRAL,
context.getString(R.string.ok),
) { _, _ ->
viewModel.onThrottlingDialogDismissed()
}
}
} else {
dialog?.dismiss()
dialog = null
setCancelable(false)
setCanceledOnTouchOutside(false)
show()
}
}
} else {
dialog?.dismiss()
dialog = null
}
}
}
/** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
@Composable
private fun UserSwitcher(
modifier: Modifier = Modifier,
) {
Box(modifier) {
Text(
text = "TODO: the user switcher goes here",
modifier = Modifier.align(Alignment.Center)
)
}
}
/**
* Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
* anywhere on the background to flip their positions.
*/
@Composable
private fun SideBySide(
viewModel: BouncerViewModel,
dialogFactory: BouncerSceneDialogFactory,
modifier: Modifier = Modifier,
) {
val layoutDirection = LocalLayoutDirection.current
val isLeftToRight = layoutDirection == LayoutDirection.Ltr
val (isUserSwitcherFirst, setUserSwitcherFirst) =
rememberSaveable(isLeftToRight) { mutableStateOf(isLeftToRight) }
Row(
modifier =
modifier.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset ->
// Depending on where the user double tapped, switch the elements such that
// the bouncer contents element is closer to the side that was double
// tapped.
setUserSwitcherFirst(offset.x > size.width / 2)
}
)
},
) {
val animatedOffset by
animateFloatAsState(
targetValue =
if (isUserSwitcherFirst) {
// When the user switcher is first, both elements have their natural
// placement so they are not offset in any way.
0f
} else if (isLeftToRight) {
// Since the user switcher is not first, the elements have to be swapped
// horizontally. In the case of LTR locales, this means pushing the user
// switcher to the right, hence the positive number.
1f
} else {
// Since the user switcher is not first, the elements have to be swapped
// horizontally. In the case of RTL locales, this means pushing the user
// switcher to the left, hence the negative number.
-1f
},
label = "offset",
)
UserSwitcher(
modifier =
Modifier.fillMaxHeight().weight(1f).graphicsLayer {
translationX = size.width * animatedOffset
},
)
Bouncer(
viewModel = viewModel,
dialogFactory = dialogFactory,
modifier =
Modifier.fillMaxHeight().weight(1f).graphicsLayer {
// A negative sign is used to make sure this is offset in the direction that's
// opposite of the direction that the user switcher is pushed in.
translationX = -size.width * animatedOffset
},
)
}
}
/** Arranges the bouncer contents and user switcher contents one on top of the other. */
@Composable
private fun Stacked(
viewModel: BouncerViewModel,
dialogFactory: BouncerSceneDialogFactory,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
UserSwitcher(
modifier = Modifier.fillMaxWidth().weight(1f),
)
Bouncer(
viewModel = viewModel,
dialogFactory = dialogFactory,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
interface BouncerSceneDialogFactory {
operator fun invoke(): AlertDialog
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment