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

Commit 24625587 authored by Will Leshner's avatar Will Leshner
Browse files

Implement an onboarding bottom sheet for hub.

Bug: 388283881
Test: atest HubOnboardingViewModelTest HubOnboardingInteractorTest
CommunalPrefsRepositoryImplTest CommunalPrefsInteractorTest
Flag: com.android.systemui.glanceable_hub_v2

Change-Id: Idbdb4281c3d77e1ed7099f3f8593845b61dd5e72
parent 399ca9c8
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ import com.android.systemui.communal.smartspace.SmartspaceInteractionHandler
import com.android.systemui.communal.ui.compose.section.AmbientStatusBarSection
import com.android.systemui.communal.ui.compose.section.CommunalPopupSection
import com.android.systemui.communal.ui.compose.section.CommunalToDreamButtonSection
import com.android.systemui.communal.ui.compose.section.HubOnboardingSection
import com.android.systemui.communal.ui.view.layout.sections.CommunalAppWidgetSection
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.keyguard.ui.composable.blueprint.BlueprintAlignmentLines
@@ -62,6 +63,7 @@ constructor(
    private val communalPopupSection: CommunalPopupSection,
    private val widgetSection: CommunalAppWidgetSection,
    private val communalToDreamButtonSection: CommunalToDreamButtonSection,
    private val hubOnboardingSection: HubOnboardingSection,
) {

    @Composable
@@ -83,6 +85,7 @@ constructor(
                            modifier = Modifier.element(Communal.Elements.Grid),
                            contentScope = this@Content,
                        )
                        with(hubOnboardingSection) { BottomSheet() }
                    }
                    if (communalSettingsInteractor.isV2FlagEnabled()) {
                        Icon(
+164 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.communal.ui.compose.section

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.outlined.ChargingStation
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.communal.ui.viewmodel.HubOnboardingViewModel
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.res.R
import com.android.systemui.statusbar.phone.ComponentSystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.statusbar.phone.createBottomSheet
import javax.inject.Inject

class HubOnboardingSection
@Inject
constructor(
    private val viewModelFactory: HubOnboardingViewModel.Factory,
    private val dialogFactory: SystemUIDialogFactory,
) {
    @Composable
    fun BottomSheet() {
        val viewModel = rememberViewModel("HubOnboardingSection") { viewModelFactory.create() }
        val shouldShowHubOnboarding by
            viewModel.shouldShowHubOnboarding.collectAsStateWithLifecycle(false)

        if (!shouldShowHubOnboarding) {
            return
        }

        HubOnboardingBottomSheet(shouldShowBottomSheet = true, dialogFactory = dialogFactory) {
            viewModel.onDismissed()
        }
    }
}

@Composable
private fun HubOnboardingBottomSheet(
    shouldShowBottomSheet: Boolean,
    dialogFactory: SystemUIDialogFactory,
    onDismiss: () -> Unit,
) {
    var dialog: ComponentSystemUIDialog? by remember { mutableStateOf(null) }
    var dismissingDueToCancel by remember { mutableStateOf(false) }

    DisposableEffect(shouldShowBottomSheet) {
        if (shouldShowBottomSheet) {
            dialog =
                dialogFactory
                    .createBottomSheet(
                        content = { HubOnboardingBottomSheetContent { dialog?.dismiss() } },
                        isDraggable = true,
                        maxWidth = 627.dp,
                    )
                    .apply {
                        setOnDismissListener {
                            // Don't set the onboarding dismissed flag if the dismiss was due to a
                            // cancel. Note that a "dismiss" is something initiated by the user
                            // (e.g. swipe down or tapping outside), while a "cancel" is a dismiss
                            // not initiated by the user (e.g. timing out to dream). We only want
                            // to mark the bottom sheet as dismissed if the user explicitly
                            // dismissed it.
                            if (!dismissingDueToCancel) {
                                onDismiss()
                            }
                        }
                        setOnCancelListener { dismissingDueToCancel = true }
                        show()
                    }
        }

        onDispose {
            dialog?.cancel()
            dialog = null
        }
    }
}

@Composable
private fun HubOnboardingBottomSheetContent(onButtonClicked: () -> Unit) {
    val colors = MaterialTheme.colorScheme
    Column(
        modifier = Modifier.fillMaxWidth().padding(48.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Icon(
            imageVector = Icons.Outlined.ChargingStation,
            contentDescription = null,
            modifier = Modifier.size(32.dp),
        )
        Spacer(modifier = Modifier.height(16.dp))
        Text(
            text = stringResource(R.string.hub_onboarding_bottom_sheet_title),
            style = MaterialTheme.typography.headlineMedium,
        )
        Spacer(modifier = Modifier.height(32.dp))
        // TODO(b/388283881): Replace with correct animations and possibly add a content description
        // if necessary.
        Image(painter = painterResource(R.drawable.hub_onboarding_bg), contentDescription = null)
        Spacer(modifier = Modifier.height(32.dp))
        Text(
            modifier = Modifier.width(300.dp),
            text = stringResource(R.string.hub_onboarding_bottom_sheet_text),
            textAlign = TextAlign.Center,
        )
        Spacer(modifier = Modifier.height(32.dp))
        Button(
            modifier = Modifier.align(Alignment.End),
            colors =
                ButtonDefaults.buttonColors(
                    containerColor = colors.primary,
                    contentColor = colors.onPrimary,
                ),
            onClick = onButtonClicked,
        ) {
            Text(
                stringResource(R.string.hub_onboarding_bottom_sheet_action_button),
                style = MaterialTheme.typography.labelLarge,
            )
        }
    }
}
+28 −0
Original line number Diff line number Diff line
@@ -87,6 +87,34 @@ class CommunalPrefsRepositoryImplTest : SysuiTestCase() {
            assertThat(isCtaDismissed).isFalse()
        }

    @Test
    fun isHubOnboardingDismissedValue_byDefault_isFalse() =
        testScope.runTest {
            val isHubOnboardingDismissed by
                collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))
            assertThat(isHubOnboardingDismissed).isFalse()
        }

    @Test
    fun isHubOnboardingDismissedValue_onSet_isTrue() =
        testScope.runTest {
            val isHubOnboardingDismissed by
                collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))

            underTest.setHubOnboardingDismissed(MAIN_USER)
            assertThat(isHubOnboardingDismissed).isTrue()
        }

    @Test
    fun isHubOnboardingDismissedValue_onSetForDifferentUser_isStillFalse() =
        testScope.runTest {
            val isHubOnboardingDismissed by
                collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))

            underTest.setHubOnboardingDismissed(SECONDARY_USER)
            assertThat(isHubOnboardingDismissed).isFalse()
        }

    @Test
    fun getSharedPreferences_whenFileRestored() =
        testScope.runTest {
+34 −0
Original line number Diff line number Diff line
@@ -74,6 +74,40 @@ class CommunalPrefsInteractorTest : SysuiTestCase() {
            assertThat(isCtaDismissed).isFalse()
        }

    @Test
    fun setHubOnboardingDismissed_currentUser() =
        testScope.runTest {
            setSelectedUser(MAIN_USER)
            val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)

            assertThat(isHubOnboardingDismissed).isFalse()
            underTest.setHubOnboardingDismissed(MAIN_USER)
            assertThat(isHubOnboardingDismissed).isTrue()
        }

    @Test
    fun setHubOnboardingDismissed_anotherUser() =
        testScope.runTest {
            setSelectedUser(MAIN_USER)
            val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)

            assertThat(isHubOnboardingDismissed).isFalse()
            underTest.setHubOnboardingDismissed(SECONDARY_USER)
            assertThat(isHubOnboardingDismissed).isFalse()
        }

    @Test
    fun isHubOnboardingDismissed_userSwitch() =
        testScope.runTest {
            setSelectedUser(MAIN_USER)
            underTest.setHubOnboardingDismissed(MAIN_USER)
            val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)

            assertThat(isHubOnboardingDismissed).isTrue()
            setSelectedUser(SECONDARY_USER)
            assertThat(isHubOnboardingDismissed).isFalse()
        }

    private suspend fun setSelectedUser(user: UserInfo) {
        with(kosmos.fakeUserRepository) {
            setUserInfos(listOf(user))
+130 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.communal.domain.interactor

import android.content.pm.UserInfo
import android.content.pm.UserInfo.FLAG_MAIN
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.ObservableTransitionState
import com.android.compose.animation.scene.SceneKey
import com.android.systemui.Flags.FLAG_GLANCEABLE_HUB_V2
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.data.repository.fakeCommunalPrefsRepository
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.settings.fakeUserTracker
import com.android.systemui.testKosmos
import com.android.systemui.user.data.repository.fakeUserRepository
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class HubOnboardingInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val sceneInteractor = kosmos.sceneInteractor

    private val underTest: HubOnboardingInteractor by lazy { kosmos.hubOnboardingInteractor }

    @Test
    @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
    fun setHubOnboardingDismissed() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            val isHubOnboardingDismissed by
                collectLastValue(fakeCommunalPrefsRepository.isHubOnboardingDismissed(MAIN_USER))

            underTest.setHubOnboardingDismissed()

            assertThat(isHubOnboardingDismissed).isTrue()
        }

    @Test
    @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
    fun shouldShowHubOnboarding_falseWhenDismissed() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)

            fakeCommunalPrefsRepository.setHubOnboardingDismissed(MAIN_USER)

            assertThat(shouldShowHubOnboarding).isFalse()
        }

    @Test
    @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
    fun shouldShowHubOnboarding_falseWhenNotIdleOnCommunal() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)

            assertThat(shouldShowHubOnboarding).isFalse()
        }

    @Test
    @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
    fun shouldShowHubOnboarding_trueWhenIdleOnCommunal() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)

            // Change to Communal scene.
            setIdleScene(Scenes.Communal)

            assertThat(shouldShowHubOnboarding).isFalse()
        }

    @Test
    @DisableFlags(FLAG_GLANCEABLE_HUB_V2)
    fun shouldShowHubOnboarding_falseWhenFlagDisabled() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)

            // Change to Communal scene.
            setIdleScene(Scenes.Communal)

            assertThat(shouldShowHubOnboarding).isFalse()
        }

    private fun setIdleScene(scene: SceneKey) {
        sceneInteractor.changeScene(scene, "test")
        val transitionState =
            MutableStateFlow<ObservableTransitionState>(ObservableTransitionState.Idle(scene))
        sceneInteractor.setTransitionState(transitionState)
    }

    private suspend fun setSelectedUser(user: UserInfo) {
        with(kosmos.fakeUserRepository) {
            setUserInfos(listOf(user))
            setSelectedUserInfo(user)
        }
        kosmos.fakeUserTracker.set(userInfos = listOf(user), selectedUserIndex = 0)
    }

    companion object {
        val MAIN_USER = UserInfo(0, "main", FLAG_MAIN)
    }
}
Loading