Loading packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt +14 −6 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, Loading @@ -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, ) { Loading @@ -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( Loading @@ -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 Loading packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +206 −23 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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, Loading Loading @@ -201,6 +224,7 @@ private fun Bouncer( } } if (viewModel.isEmergencyButtonVisible) { Button( onClick = viewModel::onEmergencyServicesButtonClicked, colors = Loading @@ -214,6 +238,7 @@ private fun Bouncer( style = MaterialTheme.typography.bodyMedium, ) } } if (dialogMessage != null) { if (dialog == null) { Loading Loading @@ -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() }, ) } } } } Loading Loading @@ -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 }, Loading @@ -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 Loading @@ -330,6 +479,7 @@ private fun Stacked( modifier = modifier, ) { UserSwitcher( viewModel = viewModel, modifier = Modifier.fillMaxWidth().weight(1f), ) Bouncer( Loading @@ -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 packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt +7 −1 Original line number Diff line number Diff line Loading @@ -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 Loading packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +90 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } Loading Loading @@ -102,6 +148,9 @@ constructor( ), ) val isEmergencyButtonVisible: Boolean get() = telephonyInteractor.hasTelephonyRadio init { if (flags.isEnabled()) { applicationScope.launch { Loading Loading @@ -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, ) } } packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt +10 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } /** Loading @@ -43,6 +49,7 @@ interface TelephonyRepository { class TelephonyRepositoryImpl @Inject constructor( @Application private val applicationContext: Context, private val manager: TelephonyListenerManager, ) : TelephonyRepository { @Annotation.CallState Loading @@ -53,4 +60,7 @@ constructor( awaitClose { manager.removeCallStateListener(listener) } } override val hasTelephonyRadio: Boolean get() = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } Loading
packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt +14 −6 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, Loading @@ -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, ) { Loading @@ -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( Loading @@ -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 Loading
packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +206 −23 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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, Loading Loading @@ -201,6 +224,7 @@ private fun Bouncer( } } if (viewModel.isEmergencyButtonVisible) { Button( onClick = viewModel::onEmergencyServicesButtonClicked, colors = Loading @@ -214,6 +238,7 @@ private fun Bouncer( style = MaterialTheme.typography.bodyMedium, ) } } if (dialogMessage != null) { if (dialog == null) { Loading Loading @@ -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() }, ) } } } } Loading Loading @@ -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 }, Loading @@ -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 Loading @@ -330,6 +479,7 @@ private fun Stacked( modifier = modifier, ) { UserSwitcher( viewModel = viewModel, modifier = Modifier.fillMaxWidth().weight(1f), ) Bouncer( Loading @@ -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
packages/SystemUI/src/com/android/systemui/bouncer/ui/BouncerViewModule.kt +7 −1 Original line number Diff line number Diff line Loading @@ -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 Loading
packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +90 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } Loading Loading @@ -102,6 +148,9 @@ constructor( ), ) val isEmergencyButtonVisible: Boolean get() = telephonyInteractor.hasTelephonyRadio init { if (flags.isEnabled()) { applicationScope.launch { Loading Loading @@ -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, ) } }
packages/SystemUI/src/com/android/systemui/telephony/data/repository/TelephonyRepository.kt +10 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } /** Loading @@ -43,6 +49,7 @@ interface TelephonyRepository { class TelephonyRepositoryImpl @Inject constructor( @Application private val applicationContext: Context, private val manager: TelephonyListenerManager, ) : TelephonyRepository { @Annotation.CallState Loading @@ -53,4 +60,7 @@ constructor( awaitClose { manager.removeCallStateListener(listener) } } override val hasTelephonyRadio: Boolean get() = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) }