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

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

Merge "[flexiglass] Adds user switcher to the landscape bouncer scene." into main

parents 41fcb870 9012414a
Loading
Loading
Loading
Loading
+14 −6
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.compose.theme.LocalAndroidColorScheme

@@ -34,11 +35,13 @@ fun PlatformButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    colors: ButtonColors = filledButtonColors(),
    verticalPadding: Dp = DefaultPlatformButtonVerticalPadding,
    content: @Composable RowScope.() -> Unit,
) {
    androidx.compose.material3.Button(
        modifier = modifier.padding(vertical = 6.dp).height(36.dp),
        colors = filledButtonColors(),
        modifier = modifier.padding(vertical = verticalPadding).height(36.dp),
        colors = colors,
        contentPadding = ButtonPaddings,
        onClick = onClick,
        enabled = enabled,
@@ -52,13 +55,16 @@ fun PlatformOutlinedButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    colors: ButtonColors = outlineButtonColors(),
    border: BorderStroke? = outlineButtonBorder(),
    verticalPadding: Dp = DefaultPlatformButtonVerticalPadding,
    content: @Composable RowScope.() -> Unit,
) {
    androidx.compose.material3.OutlinedButton(
        modifier = modifier.padding(vertical = 6.dp).height(36.dp),
        modifier = modifier.padding(vertical = verticalPadding).height(36.dp),
        enabled = enabled,
        colors = outlineButtonColors(),
        border = outlineButtonBorder(),
        colors = colors,
        border = border,
        contentPadding = ButtonPaddings,
        onClick = onClick,
    ) {
@@ -71,6 +77,7 @@ fun PlatformTextButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    colors: ButtonColors = textButtonColors(),
    content: @Composable RowScope.() -> Unit,
) {
    androidx.compose.material3.TextButton(
@@ -78,10 +85,11 @@ fun PlatformTextButton(
        modifier = modifier,
        enabled = enabled,
        content = content,
        colors = textButtonColors(),
        colors = colors,
    )
}

private val DefaultPlatformButtonVerticalPadding = 6.dp
private val ButtonPaddings = PaddingValues(horizontal = 16.dp, vertical = 8.dp)

@Composable
+206 −23
Original line number Diff line number Diff line
@@ -24,18 +24,30 @@ 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.Image
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@@ -48,12 +60,18 @@ 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.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import com.android.compose.PlatformButton
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.SceneScope
import com.android.compose.windowsizeclass.LocalWindowSizeClass
@@ -62,6 +80,8 @@ 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.common.shared.model.Text.Companion.loadText
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.res.R
import com.android.systemui.scene.shared.model.Direction
@@ -70,6 +90,9 @@ import com.android.systemui.scene.shared.model.SceneModel
import com.android.systemui.scene.shared.model.UserAction
import com.android.systemui.scene.ui.composable.ComposableScene
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -164,7 +187,7 @@ private fun Bouncer(
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(60.dp),
        modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp)
        modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 92.dp)
    ) {
        Crossfade(
            targetState = message,
@@ -201,6 +224,7 @@ private fun Bouncer(
            }
        }

        if (viewModel.isEmergencyButtonVisible) {
            Button(
                onClick = viewModel::onEmergencyServicesButtonClicked,
                colors =
@@ -214,6 +238,7 @@ private fun Bouncer(
                    style = MaterialTheme.typography.bodyMedium,
                )
            }
        }

        if (dialogMessage != null) {
            if (dialog == null) {
@@ -241,13 +266,130 @@ private fun Bouncer(
/** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
@Composable
private fun UserSwitcher(
    viewModel: BouncerViewModel,
    modifier: Modifier = Modifier,
) {
    Box(modifier) {
    val selectedUserImage by viewModel.selectedUserImage.collectAsState(null)
    val dropdownItems by viewModel.userSwitcherDropdown.collectAsState(emptyList())

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = modifier,
    ) {
        selectedUserImage?.let {
            Image(
                bitmap = it.asImageBitmap(),
                contentDescription = null,
                modifier = Modifier.size(SelectedUserImageSize),
            )
        }

        UserSwitcherDropdown(
            items = dropdownItems,
        )
    }
}

@Composable
private fun UserSwitcherDropdown(
    items: List<BouncerViewModel.UserSwitcherDropdownItemViewModel>,
) {
    val (isDropdownExpanded, setDropdownExpanded) = remember { mutableStateOf(false) }

    items.firstOrNull()?.let { firstDropdownItem ->
        Spacer(modifier = Modifier.height(40.dp))

        Box {
            PlatformButton(
                modifier =
                    Modifier
                        // Remove the built-in padding applied inside PlatformButton:
                        .padding(vertical = 0.dp)
                        .width(UserSwitcherDropdownWidth)
                        .height(UserSwitcherDropdownHeight),
                colors =
                    ButtonDefaults.buttonColors(
                        containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
                        contentColor = MaterialTheme.colorScheme.onSurface,
                    ),
                onClick = { setDropdownExpanded(!isDropdownExpanded) },
            ) {
                val context = LocalContext.current
                Text(
                    text = checkNotNull(firstDropdownItem.text.loadText(context)),
                    style = MaterialTheme.typography.headlineSmall,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )

                Spacer(modifier = Modifier.weight(1f))

                Icon(
                    imageVector = Icons.Default.KeyboardArrowDown,
                    contentDescription = null,
                    modifier = Modifier.size(32.dp),
                )
            }

            UserSwitcherDropdownMenu(
                isExpanded = isDropdownExpanded,
                items = items,
                onDismissed = { setDropdownExpanded(false) },
            )
        }
    }
}

@Composable
private fun UserSwitcherDropdownMenu(
    isExpanded: Boolean,
    items: List<BouncerViewModel.UserSwitcherDropdownItemViewModel>,
    onDismissed: () -> Unit,
) {
    val context = LocalContext.current

    // TODO(b/303071855): once the FR is fixed, remove this composition local override.
    MaterialTheme(
        colorScheme =
            MaterialTheme.colorScheme.copy(
                surface = MaterialTheme.colorScheme.surfaceContainerHighest,
            ),
        shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(28.dp)),
    ) {
        DropdownMenu(
            expanded = isExpanded,
            onDismissRequest = onDismissed,
            offset =
                DpOffset(
                    x = 0.dp,
                    y = -UserSwitcherDropdownHeight,
                ),
            modifier = Modifier.width(UserSwitcherDropdownWidth),
        ) {
            items.forEach { userSwitcherDropdownItem ->
                DropdownMenuItem(
                    leadingIcon = {
                        Icon(
                            icon = userSwitcherDropdownItem.icon,
                            tint = MaterialTheme.colorScheme.primary,
                            modifier = Modifier.size(28.dp),
                        )
                    },
                    text = {
                        Text(
            text = "TODO: the user switcher goes here",
            modifier = Modifier.align(Alignment.Center)
                            text = checkNotNull(userSwitcherDropdownItem.text.loadText(context)),
                            style = MaterialTheme.typography.bodyLarge,
                            color = MaterialTheme.colorScheme.onSurface,
                        )
                    },
                    onClick = {
                        onDismissed()
                        userSwitcherDropdownItem.onClick()
                    },
                )
            }
        }
    }
}

@@ -293,7 +435,7 @@ private fun SideBySide(
                        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
                        // horizontally. In the case of RTL locale, this means pushing the user
                        // switcher to the left, hence the negative number.
                        -1f
                    },
@@ -301,23 +443,30 @@ private fun SideBySide(
            )

        UserSwitcher(
            viewModel = viewModel,
            modifier =
                Modifier.fillMaxHeight().weight(1f).graphicsLayer {
                    translationX = size.width * animatedOffset
                    alpha = animatedAlpha(animatedOffset)
                },
        )
        Bouncer(
            viewModel = viewModel,
            dialogFactory = dialogFactory,
        Box(
            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
                },
                    alpha = animatedAlpha(animatedOffset)
                }
        ) {
            Bouncer(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                modifier = Modifier.widthIn(max = 400.dp).align(Alignment.BottomCenter),
            )
        }
    }
}

/** Arranges the bouncer contents and user switcher contents one on top of the other. */
@Composable
@@ -330,6 +479,7 @@ private fun Stacked(
        modifier = modifier,
    ) {
        UserSwitcher(
            viewModel = viewModel,
            modifier = Modifier.fillMaxWidth().weight(1f),
        )
        Bouncer(
@@ -343,3 +493,36 @@ private fun Stacked(
interface BouncerSceneDialogFactory {
    operator fun invoke(): AlertDialog
}

/**
 * Calculates an alpha for the user switcher and bouncer such that it's at `1` when the offset of
 * the two reaches a stopping point but `0` in the middle of the transition.
 */
private fun animatedAlpha(
    offset: Float,
): Float {
    // Describes a curve that is made of two parabolic U-shaped curves mirrored horizontally around
    // the y-axis. The U on the left runs between x = -1 and x = 0 while the U on the right runs
    // between x = 0 and x = 1.
    //
    // The minimum values of the curves are at -0.5 and +0.5.
    //
    // Both U curves are vertically scaled such that they reach the points (-1, 1) and (1, 1).
    //
    // Breaking it down, it's y = a×(|x|-m)²+b, where:
    // x: the offset
    // y: the alpha
    // m: x-axis center of the parabolic curves, where the minima are.
    // b: y-axis offset to apply to the entire curve so the animation spends more time with alpha =
    // 0.
    // a: amplitude to scale the parabolic curves to reach y = 1 at x = -1, x = 0, and x = +1.
    val m = 0.5f
    val b = -0.25
    val a = (1 - b) / m.pow(2)

    return max(0f, (a * (abs(offset) - m).pow(2) + b).toFloat())
}

private val SelectedUserImageSize = 190.dp
private val UserSwitcherDropdownWidth = SelectedUserImageSize + 2 * 29.dp
private val UserSwitcherDropdownHeight = 60.dp
+7 −1
Original line number Diff line number Diff line
@@ -16,10 +16,16 @@

package com.android.systemui.bouncer.ui

import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModelModule
import dagger.Binds
import dagger.Module

@Module
@Module(
    includes =
        [
            BouncerViewModelModule::class,
        ],
)
interface BouncerViewModule {
    /** Binds BouncerView to BouncerViewImpl and makes it injectable. */
    @Binds fun bindBouncerView(bouncerViewImpl: BouncerViewImpl): BouncerView
+90 −5
Original line number Diff line number Diff line
@@ -17,19 +17,29 @@
package com.android.systemui.bouncer.ui.viewmodel

import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
import com.android.systemui.authentication.domain.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.scene.shared.flag.SceneContainerFlags
import javax.inject.Inject
import com.android.systemui.telephony.domain.interactor.TelephonyInteractor
import com.android.systemui.user.ui.viewmodel.UserActionViewModel
import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
import com.android.systemui.user.ui.viewmodel.UserViewModel
import dagger.Module
import dagger.Provides
import kotlin.math.ceil
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -42,17 +52,53 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.launch

/** Holds UI state and handles user input on bouncer UIs. */
@SysUISingleton
class BouncerViewModel
@Inject
constructor(
class BouncerViewModel(
    @Application private val applicationContext: Context,
    @Application private val applicationScope: CoroutineScope,
    @Main private val mainDispatcher: CoroutineDispatcher,
    private val bouncerInteractor: BouncerInteractor,
    authenticationInteractor: AuthenticationInteractor,
    flags: SceneContainerFlags,
    private val telephonyInteractor: TelephonyInteractor,
    selectedUser: Flow<UserViewModel>,
    users: Flow<List<UserViewModel>>,
    userSwitcherMenu: Flow<List<UserActionViewModel>>,
) {
    val selectedUserImage: StateFlow<Bitmap?> =
        selectedUser
            .map { it.image.toBitmap() }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = null,
            )

    val userSwitcherDropdown: StateFlow<List<UserSwitcherDropdownItemViewModel>> =
        combine(
                users,
                userSwitcherMenu,
            ) { users, actions ->
                users.map { user ->
                    UserSwitcherDropdownItemViewModel(
                        icon = Icon.Loaded(user.image, contentDescription = null),
                        text = user.name,
                        onClick = user.onClicked ?: {},
                    )
                } +
                    actions.map { action ->
                        UserSwitcherDropdownItemViewModel(
                            icon = Icon.Resource(action.iconResourceId, contentDescription = null),
                            text = Text.Resource(action.textResourceId),
                            onClick = action.onClicked,
                        )
                    }
            }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = emptyList(),
            )

    private val isInputEnabled: StateFlow<Boolean> =
        bouncerInteractor.isThrottled
            .map { !it }
@@ -102,6 +148,9 @@ constructor(
                    ),
            )

    val isEmergencyButtonVisible: Boolean
        get() = telephonyInteractor.hasTelephonyRadio

    init {
        if (flags.isEnabled()) {
            applicationScope.launch {
@@ -200,4 +249,40 @@ constructor(
         */
        val isUpdateAnimated: Boolean,
    )

    data class UserSwitcherDropdownItemViewModel(
        val icon: Icon,
        val text: Text,
        val onClick: () -> Unit,
    )
}

@Module
object BouncerViewModelModule {

    @Provides
    @SysUISingleton
    fun viewModel(
        @Application applicationContext: Context,
        @Application applicationScope: CoroutineScope,
        @Main mainDispatcher: CoroutineDispatcher,
        bouncerInteractor: BouncerInteractor,
        authenticationInteractor: AuthenticationInteractor,
        flags: SceneContainerFlags,
        telephonyInteractor: TelephonyInteractor,
        userSwitcherViewModel: UserSwitcherViewModel,
    ): BouncerViewModel {
        return BouncerViewModel(
            applicationContext = applicationContext,
            applicationScope = applicationScope,
            mainDispatcher = mainDispatcher,
            bouncerInteractor = bouncerInteractor,
            authenticationInteractor = authenticationInteractor,
            flags = flags,
            telephonyInteractor = telephonyInteractor,
            selectedUser = userSwitcherViewModel.selectedUser,
            users = userSwitcherViewModel.users,
            userSwitcherMenu = userSwitcherViewModel.menu,
        )
    }
}
+10 −0
Original line number Diff line number Diff line
@@ -17,10 +17,13 @@

package com.android.systemui.telephony.data.repository

import android.content.Context
import android.content.pm.PackageManager
import android.telephony.Annotation
import android.telephony.TelephonyCallback
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.telephony.TelephonyListenerManager
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
@@ -30,6 +33,9 @@ import kotlinx.coroutines.flow.Flow
interface TelephonyRepository {
    /** The state of the current call. */
    @Annotation.CallState val callState: Flow<Int>

    /** Whether the device has a radio that can be used for telephony. */
    val hasTelephonyRadio: Boolean
}

/**
@@ -43,6 +49,7 @@ interface TelephonyRepository {
class TelephonyRepositoryImpl
@Inject
constructor(
    @Application private val applicationContext: Context,
    private val manager: TelephonyListenerManager,
) : TelephonyRepository {
    @Annotation.CallState
@@ -53,4 +60,7 @@ constructor(

        awaitClose { manager.removeCallStateListener(listener) }
    }

    override val hasTelephonyRadio: Boolean
        get() = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
}
Loading