diff options
| author | 2024-04-30 15:44:10 +0000 | |
|---|---|---|
| committer | 2024-05-02 16:16:29 +0000 | |
| commit | 47642303df8affc8e6151c4d9dbde1d95430cdbc (patch) | |
| tree | e625c374d7ea9375ed22a0f512ce65ac6567500c | |
| parent | f747d86367c8016df743683549fd9e375650b4a9 (diff) | |
[bc25] Create `OverlayShade`, a shared UI container for overlay shades.
The overlay shade renders the shared parts of the lightweight overlay
shades (notifications shade and quick settings shade):
* Lockscreen (if the shade was invoked on the lockscreen)
* Semi-transparent scrim
* A rounded-corner UI panel whose width varies based on the screen size
* Transitions (will be added in a future CL)
Test: Added unit tests.
Bug: 337849926.
Flag: ACONFIG com.android.systemui.dual_shade DISABLED
Change-Id: Ic2bf1433b884e7c02e78a9d437913084230abf56
4 files changed, 411 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/OverlayShade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/OverlayShade.kt new file mode 100644 index 000000000000..d5287362c36d --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/OverlayShade.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024 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.shade.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.LowestZIndexScenePicker +import com.android.compose.animation.scene.SceneScope +import com.android.systemui.scene.shared.model.Scenes +import com.android.systemui.shade.ui.viewmodel.OverlayShadeViewModel + +/** The overlay shade renders a lightweight shade UI container on top of a background scene. */ +@Composable +fun SceneScope.OverlayShade( + viewModel: OverlayShadeViewModel, + horizontalArrangement: Arrangement.Horizontal, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val backgroundScene by viewModel.backgroundScene.collectAsState() + + Box(modifier) { + if (backgroundScene == Scenes.Lockscreen) { + Lockscreen() + } + + Scrim(onClicked = viewModel::onScrimClicked) + + Row( + modifier = Modifier.fillMaxSize().padding(OverlayShade.Dimensions.ScrimContentPadding), + horizontalArrangement = horizontalArrangement, + ) { + Panel(content = content) + } + } +} + +@Composable +private fun Lockscreen( + modifier: Modifier = Modifier, +) { + // TODO(b/338025605): This is a placeholder, replace with the actual lockscreen. + Box(modifier = modifier.fillMaxSize().background(Color.LightGray)) { + Text(text = "Lockscreen", modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun SceneScope.Scrim( + onClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Spacer( + modifier = + modifier + .element(OverlayShade.Elements.Scrim) + .fillMaxSize() + .background(OverlayShade.Colors.ScrimBackground) + .clickable(onClick = onClicked, interactionSource = null, indication = null) + ) +} + +@Composable +private fun SceneScope.Panel( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = + modifier + .width(OverlayShade.Dimensions.PanelWidth) + .clip(OverlayShade.Shapes.RoundedCornerPanel) + ) { + Spacer( + modifier = + Modifier.element(OverlayShade.Elements.PanelBackground) + .matchParentSize() + .background( + color = OverlayShade.Colors.PanelBackground, + shape = OverlayShade.Shapes.RoundedCornerPanel, + ), + ) + + // This content is intentionally rendered as a separate element from the background in order + // to allow for more flexibility when defining transitions. + content() + } +} + +object OverlayShade { + object Elements { + val Scrim = ElementKey("OverlayShadeScrim", scenePicker = LowestZIndexScenePicker) + val PanelBackground = + ElementKey("OverlayShadePanelBackground", scenePicker = LowestZIndexScenePicker) + } + + object Colors { + val ScrimBackground = Color(0, 0, 0, alpha = 255 / 3) + val PanelBackground: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainer + } + + object Dimensions { + val ScrimContentPadding = 16.dp + val PanelCornerRadius = 46.dp + // TODO(b/338033836): This width should not be fixed. + val PanelWidth = 390.dp + } + + object Shapes { + val RoundedCornerPanel = RoundedCornerShape(Dimensions.PanelCornerRadius) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelTest.kt new file mode 100644 index 000000000000..0ffabd807ba7 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 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.shade.ui.viewmodel + +import android.testing.TestableLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository +import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor +import com.android.systemui.flags.EnableSceneContainer +import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository +import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus +import com.android.systemui.kosmos.testScope +import com.android.systemui.scene.domain.interactor.sceneInteractor +import com.android.systemui.scene.shared.model.Scenes +import com.android.systemui.testKosmos +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +@TestableLooper.RunWithLooper +@EnableSceneContainer +class OverlayShadeViewModelTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private val testScope = kosmos.testScope + private val sceneInteractor = kosmos.sceneInteractor + private val deviceUnlockedInteractor by lazy { kosmos.deviceUnlockedInteractor } + + private val underTest = kosmos.overlayShadeViewModel + + @Test + fun backgroundScene_deviceLocked_lockscreen() = + testScope.runTest { + val backgroundScene by collectLastValue(underTest.backgroundScene) + + lockDevice() + + assertThat(backgroundScene).isEqualTo(Scenes.Lockscreen) + } + + @Test + fun backgroundScene_deviceUnlocked_gone() = + testScope.runTest { + val backgroundScene by collectLastValue(underTest.backgroundScene) + + lockDevice() + unlockDevice() + + assertThat(backgroundScene).isEqualTo(Scenes.Gone) + } + + @Test + fun backgroundScene_authMethodSwipe_lockscreenNotDismissed_goesToLockscreen() = + testScope.runTest { + val backgroundScene by collectLastValue(underTest.backgroundScene) + val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus) + + kosmos.fakeDeviceEntryRepository.setLockscreenEnabled(true) + kosmos.fakeAuthenticationRepository.setAuthenticationMethod( + AuthenticationMethodModel.None + ) + assertThat(deviceUnlockStatus?.isUnlocked).isTrue() + sceneInteractor.changeScene(Scenes.Lockscreen, "reason") + runCurrent() + + assertThat(backgroundScene).isEqualTo(Scenes.Lockscreen) + } + + @Test + fun backgroundScene_authMethodSwipe_lockscreenDismissed_goesToGone() = + testScope.runTest { + val backgroundScene by collectLastValue(underTest.backgroundScene) + val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus) + + kosmos.fakeDeviceEntryRepository.setLockscreenEnabled(true) + kosmos.fakeAuthenticationRepository.setAuthenticationMethod( + AuthenticationMethodModel.None + ) + assertThat(deviceUnlockStatus?.isUnlocked).isTrue() + sceneInteractor.changeScene(Scenes.Gone, "reason") + runCurrent() + + assertThat(backgroundScene).isEqualTo(Scenes.Gone) + } + + @Test + fun onScrimClicked_onLockscreen_goesToLockscreen() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene) + lockDevice() + sceneInteractor.changeScene(Scenes.Bouncer, "reason") + runCurrent() + assertThat(currentScene).isNotEqualTo(Scenes.Lockscreen) + + underTest.onScrimClicked() + + assertThat(currentScene).isEqualTo(Scenes.Lockscreen) + } + + @Test + fun onScrimClicked_deviceWasEntered_goesToGone() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene) + val backgroundScene by collectLastValue(underTest.backgroundScene) + + lockDevice() + unlockDevice() + sceneInteractor.changeScene(Scenes.QuickSettings, "reason") + runCurrent() + assertThat(backgroundScene).isEqualTo(Scenes.Gone) + assertThat(currentScene).isNotEqualTo(Scenes.Gone) + + underTest.onScrimClicked() + + assertThat(currentScene).isEqualTo(Scenes.Gone) + } + + private fun TestScope.lockDevice() { + val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus) + + kosmos.fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin) + assertThat(deviceUnlockStatus?.isUnlocked).isFalse() + sceneInteractor.changeScene(Scenes.Lockscreen, "reason") + runCurrent() + } + + private fun TestScope.unlockDevice() { + val deviceUnlockStatus by collectLastValue(deviceUnlockedInteractor.deviceUnlockStatus) + + kosmos.fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus( + SuccessFingerprintAuthenticationStatus(0, true) + ) + assertThat(deviceUnlockStatus?.isUnlocked).isTrue() + sceneInteractor.changeScene(Scenes.Gone, "reason") + runCurrent() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt new file mode 100644 index 000000000000..b8dd62897587 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 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.shade.ui.viewmodel + +import com.android.compose.animation.scene.SceneKey +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.Scenes +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * Models UI state and handles user input for the overlay shade UI, which shows a shade as an + * overlay on top of another scene UI. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@SysUISingleton +class OverlayShadeViewModel +@Inject +constructor( + @Application private val applicationScope: CoroutineScope, + private val sceneInteractor: SceneInteractor, + deviceEntryInteractor: DeviceEntryInteractor, +) { + /** The scene to show in the background when the overlay shade is open. */ + val backgroundScene: StateFlow<SceneKey> = + deviceEntryInteractor.isDeviceEntered + .map(::backgroundScene) + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = backgroundScene(deviceEntryInteractor.isDeviceEntered.value) + ) + + /** Notifies that the user has clicked the semi-transparent background scrim. */ + fun onScrimClicked() { + sceneInteractor.changeScene( + toScene = backgroundScene.value, + loggingReason = "Shade scrim clicked", + ) + } + + private fun backgroundScene(isDeviceEntered: Boolean): SceneKey { + return if (isDeviceEntered) Scenes.Gone else Scenes.Lockscreen + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelKosmos.kt new file mode 100644 index 000000000000..45ec03241495 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/OverlayShadeViewModelKosmos.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 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(ExperimentalCoroutinesApi::class) + +package com.android.systemui.shade.ui.viewmodel + +import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.applicationCoroutineScope +import com.android.systemui.scene.domain.interactor.sceneInteractor +import kotlinx.coroutines.ExperimentalCoroutinesApi + +val Kosmos.overlayShadeViewModel: OverlayShadeViewModel by + Kosmos.Fixture { + OverlayShadeViewModel( + applicationScope = applicationCoroutineScope, + sceneInteractor = sceneInteractor, + deviceEntryInteractor = deviceEntryInteractor, + ) + } |