Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a11b92b7 authored by Ale Nijamkin's avatar Ale Nijamkin Committed by Android (Google) Code Review
Browse files

Merge changes Icb51a009,I1dff6dc1,I5effca8f into udc-dev

* changes:
  [flexiglass] Introduces SceneTestUtils.
  [flexiglass] Multiple input method bouncer scene.
  Adds horizontal/vertical grids.
parents a013cc27 d7438b71
Loading
Loading
Loading
Loading
+190 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.grid

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.roundToInt

/**
 * Renders a grid with [columns] columns.
 *
 * Child composables will be arranged row by row.
 *
 * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell
 * inside a column is spaced from the cells above and below it with [verticalSpacing].
 */
@Composable
fun VerticalGrid(
    columns: Int,
    modifier: Modifier = Modifier,
    verticalSpacing: Dp = 0.dp,
    horizontalSpacing: Dp = 0.dp,
    content: @Composable () -> Unit,
) {
    Grid(
        primarySpaces = columns,
        isVertical = true,
        modifier = modifier,
        verticalSpacing = verticalSpacing,
        horizontalSpacing = horizontalSpacing,
        content = content,
    )
}

/**
 * Renders a grid with [rows] rows.
 *
 * Child composables will be arranged column by column.
 *
 * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell
 * inside a column is spaced from the cells above and below it with [verticalSpacing].
 */
@Composable
fun HorizontalGrid(
    rows: Int,
    modifier: Modifier = Modifier,
    verticalSpacing: Dp = 0.dp,
    horizontalSpacing: Dp = 0.dp,
    content: @Composable () -> Unit,
) {
    Grid(
        primarySpaces = rows,
        isVertical = false,
        modifier = modifier,
        verticalSpacing = verticalSpacing,
        horizontalSpacing = horizontalSpacing,
        content = content,
    )
}

@Composable
private fun Grid(
    primarySpaces: Int,
    isVertical: Boolean,
    modifier: Modifier = Modifier,
    verticalSpacing: Dp,
    horizontalSpacing: Dp,
    content: @Composable () -> Unit,
) {
    check(primarySpaces > 0) {
        "Must provide a positive number of ${if (isVertical) "columns" else "rows"}"
    }

    val sizeCache = remember {
        object {
            var rowHeights = intArrayOf()
            var columnWidths = intArrayOf()
        }
    }

    Layout(
        modifier = modifier,
        content = content,
    ) { measurables, constraints ->
        val cells = measurables.size
        val columns: Int
        val rows: Int
        if (isVertical) {
            columns = primarySpaces
            rows = ceil(cells.toFloat() / primarySpaces).toInt()
        } else {
            columns = ceil(cells.toFloat() / primarySpaces).toInt()
            rows = primarySpaces
        }

        if (sizeCache.rowHeights.size != rows) {
            sizeCache.rowHeights = IntArray(rows) { 0 }
        }
        if (sizeCache.columnWidths.size != columns) {
            sizeCache.columnWidths = IntArray(columns) { 0 }
        }

        val totalHorizontalSpacingBetweenChildren =
            ((columns - 1) * horizontalSpacing.toPx()).roundToInt()
        val totalVerticalSpacingBetweenChildren = ((rows - 1) * verticalSpacing.toPx()).roundToInt()
        val childConstraints =
            Constraints().apply {
                if (constraints.maxWidth != Constraints.Infinity) {
                    constrainWidth(
                        (constraints.maxWidth - totalHorizontalSpacingBetweenChildren) / columns
                    )
                }
                if (constraints.maxHeight != Constraints.Infinity) {
                    constrainWidth(
                        (constraints.maxHeight - totalVerticalSpacingBetweenChildren) / rows
                    )
                }
            }

        val placeables = buildList {
            for (cellIndex in measurables.indices) {
                val column: Int
                val row: Int
                if (isVertical) {
                    column = cellIndex % columns
                    row = cellIndex / columns
                } else {
                    column = cellIndex / rows
                    row = cellIndex % rows
                }

                val placeable = measurables[cellIndex].measure(childConstraints)
                sizeCache.rowHeights[row] = max(sizeCache.rowHeights[row], placeable.height)
                sizeCache.columnWidths[column] =
                    max(sizeCache.columnWidths[column], placeable.width)
                add(placeable)
            }
        }

        var totalWidth = totalHorizontalSpacingBetweenChildren
        for (column in sizeCache.columnWidths.indices) {
            totalWidth += sizeCache.columnWidths[column]
        }

        var totalHeight = totalVerticalSpacingBetweenChildren
        for (row in sizeCache.rowHeights.indices) {
            totalHeight += sizeCache.rowHeights[row]
        }

        layout(totalWidth, totalHeight) {
            var y = 0
            repeat(rows) { row ->
                var x = 0
                var maxChildHeight = 0
                repeat(columns) { column ->
                    val cellIndex = row * columns + column
                    if (cellIndex < cells) {
                        val placeable = placeables[cellIndex]
                        placeable.placeRelative(x, y)
                        x += placeable.width + horizontalSpacing.roundToPx()
                        maxChildHeight = max(maxChildHeight, placeable.height)
                    }
                }
                y += maxChildHeight + verticalSpacing.roundToPx()
            }
        }
    }
}
+73 −18
Original line number Diff line number Diff line
@@ -16,18 +16,30 @@

package com.android.systemui.bouncer.ui.composable

import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
@@ -55,26 +67,69 @@ constructor(
            )
            .asStateFlow()

    @Composable override fun Content(modifier: Modifier) = BouncerScene(viewModel, modifier)
}

@Composable
    override fun Content(
        modifier: Modifier,
private fun BouncerScene(
    viewModel: BouncerViewModel,
    modifier: Modifier = Modifier,
) {
        // TODO(b/280877228): implement the real UI.
    val message: String by viewModel.message.collectAsState()
    val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()

        Box(modifier = modifier) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.align(Alignment.Center)
        verticalArrangement = Arrangement.spacedBy(60.dp),
        modifier =
            modifier.background(MaterialTheme.colorScheme.surface).fillMaxSize().padding(32.dp)
    ) {
                Text("Bouncer", style = MaterialTheme.typography.headlineLarge)
                Row(
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
        Crossfade(
            targetState = message,
            label = "Bouncer message",
        ) {
                    Button(onClick = { viewModel.onAuthenticateButtonClicked() }) {
                        Text("Authenticate")
            Text(
                text = it,
                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,
            )
        }
    }
}
+99 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.bouncer.ui.composable

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel

/** UI for the input part of a password-requiring version of the bouncer. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PasswordBouncer(
    viewModel: PasswordBouncerViewModel,
    modifier: Modifier = Modifier,
) {
    val focusRequester = remember { FocusRequester() }
    val password: String by viewModel.password.collectAsState()

    LaunchedEffect(Unit) {
        // When the UI comes up, request focus on the TextField to bring up the software keyboard.
        focusRequester.requestFocus()
        // Also, report that the UI is shown to let the view-model runs some logic.
        viewModel.onShown()
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = modifier,
    ) {
        val color = MaterialTheme.colorScheme.onSurfaceVariant
        val lineWidthPx = with(LocalDensity.current) { 2.dp.toPx() }

        TextField(
            value = password,
            onValueChange = viewModel::onPasswordInputChanged,
            visualTransformation = PasswordVisualTransformation(),
            singleLine = true,
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
            keyboardOptions =
                KeyboardOptions(
                    keyboardType = KeyboardType.Password,
                    imeAction = ImeAction.Done,
                ),
            keyboardActions =
                KeyboardActions(
                    onDone = { viewModel.onAuthenticateKeyPressed() },
                ),
            modifier =
                Modifier.focusRequester(focusRequester).drawBehind {
                    drawLine(
                        color = color,
                        start = Offset(x = 0f, y = size.height - lineWidthPx),
                        end = Offset(size.width, y = size.height - lineWidthPx),
                        strokeWidth = lineWidthPx,
                    )
                },
        )

        Spacer(Modifier.height(100.dp))
    }
}
+281 −0

File added.

Preview size limit exceeded, changes collapsed.

+247 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:OptIn(ExperimentalAnimationApi::class)

package com.android.systemui.bouncer.ui.composable

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.compose.grid.VerticalGrid
import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import kotlin.math.max

@Composable
internal fun PinBouncer(
    viewModel: PinBouncerViewModel,
    modifier: Modifier = Modifier,
) {
    // Report that the UI is shown to let the view-model run some logic.
    LaunchedEffect(Unit) { viewModel.onShown() }

    // The length of the PIN input received so far, so we know how many dots to render.
    val pinLength: Pair<Int, Int> by viewModel.pinLengths.collectAsState()

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = modifier,
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            modifier = Modifier.heightIn(min = 16.dp).animateContentSize(),
        ) {
            // TODO(b/281871687): add support for dot shapes.
            val (previousPinLength, currentPinLength) = pinLength
            val dotCount = max(previousPinLength, currentPinLength) + 1
            repeat(dotCount) { index ->
                AnimatedVisibility(
                    visible = index < currentPinLength,
                    enter = fadeIn() + scaleIn() + slideInHorizontally(),
                    exit = fadeOut() + scaleOut() + slideOutHorizontally(),
                ) {
                    Box(
                        modifier =
                            Modifier.size(16.dp)
                                .background(
                                    MaterialTheme.colorScheme.onSurfaceVariant,
                                    CircleShape,
                                )
                    )
                }
            }
        }

        Spacer(Modifier.height(100.dp))

        VerticalGrid(
            columns = 3,
            verticalSpacing = 12.dp,
            horizontalSpacing = 20.dp,
        ) {
            repeat(9) { index ->
                val digit = index + 1
                PinButton(
                    onClicked = { viewModel.onPinButtonClicked(digit) },
                ) { contentColor ->
                    PinDigit(digit, contentColor)
                }
            }

            PinButton(
                onClicked = { viewModel.onBackspaceButtonClicked() },
                onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
                isHighlighted = true,
            ) { contentColor ->
                PinIcon(
                    Icon.Resource(
                        res = R.drawable.ic_backspace_24dp,
                        contentDescription =
                            ContentDescription.Resource(R.string.keyboardview_keycode_delete),
                    ),
                    contentColor,
                )
            }

            PinButton(
                onClicked = { viewModel.onPinButtonClicked(0) },
            ) { contentColor ->
                PinDigit(0, contentColor)
            }

            PinButton(
                onClicked = { viewModel.onAuthenticateButtonClicked() },
                isHighlighted = true,
            ) { contentColor ->
                PinIcon(
                    Icon.Resource(
                        res = R.drawable.ic_keyboard_tab_36dp,
                        contentDescription =
                            ContentDescription.Resource(R.string.keyboardview_keycode_enter),
                    ),
                    contentColor,
                )
            }
        }
    }
}

@Composable
private fun PinDigit(
    digit: Int,
    contentColor: Color,
) {
    // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes it
    //  into Text, use that here, to animate more efficiently.
    Text(
        text = digit.toString(),
        style = MaterialTheme.typography.headlineLarge,
        color = contentColor,
    )
}

@Composable
private fun PinIcon(
    icon: Icon,
    contentColor: Color,
) {
    Icon(
        icon = icon,
        tint = contentColor,
    )
}

@Composable
private fun PinButton(
    onClicked: () -> Unit,
    modifier: Modifier = Modifier,
    onLongPressed: (() -> Unit)? = null,
    isHighlighted: Boolean = false,
    content: @Composable (contentColor: Color) -> Unit,
) {
    var isPressed: Boolean by remember { mutableStateOf(false) }
    val cornerRadius: Dp by
        animateDpAsState(
            if (isPressed) 24.dp else PinButtonSize / 2,
            label = "PinButton round corners",
        )
    val containerColor: Color by
        animateColorAsState(
            when {
                isPressed -> MaterialTheme.colorScheme.primaryContainer
                isHighlighted -> MaterialTheme.colorScheme.secondaryContainer
                else -> MaterialTheme.colorScheme.surface
            },
            label = "Pin button container color",
        )
    val contentColor: Color by
        animateColorAsState(
            when {
                isPressed -> MaterialTheme.colorScheme.onPrimaryContainer
                isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer
                else -> MaterialTheme.colorScheme.onSurface
            },
            label = "Pin button container color",
        )

    Box(
        contentAlignment = Alignment.Center,
        modifier =
            modifier
                .size(PinButtonSize)
                .drawBehind {
                    drawRoundRect(
                        color = containerColor,
                        cornerRadius = CornerRadius(cornerRadius.toPx()),
                    )
                }
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = {
                            isPressed = true
                            tryAwaitRelease()
                            isPressed = false
                        },
                        onTap = { onClicked() },
                        onLongPress = onLongPressed?.let { { onLongPressed() } },
                    )
                },
    ) {
        content(contentColor)
    }
}

private val PinButtonSize = 84.dp
Loading