diff options
| author | 2025-01-22 12:18:16 -0800 | |
|---|---|---|
| committer | 2025-01-22 12:18:16 -0800 | |
| commit | dfab2fa8b0d0dda14dd1dbb78374fc8b2bd1ec95 (patch) | |
| tree | 1042bdd699ff28d66536cfbc65bd6b6caf11e9dd | |
| parent | a8891401ad9a4bbf375cb0f386be78bba1c6e65e (diff) | |
| parent | 24625587cd9626c3afedd91296a491d1da27b381 (diff) | |
Merge "Implement an onboarding bottom sheet for hub." into main
15 files changed, 637 insertions, 8 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt index fea34921b853..30dfa5bb826a 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt @@ -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( diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt new file mode 100644 index 000000000000..6943e9b00ed8 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt @@ -0,0 +1,164 @@ +/* + * 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, + ) + } + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt index 1f5e30ca4a0d..0d410cff5ff6 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt @@ -88,6 +88,34 @@ class CommunalPrefsRepositoryImplTest : SysuiTestCase() { } @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 { val isCtaDismissed by collectLastValue(underTest.isCtaDismissed(MAIN_USER)) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt index 9a92f76f90c6..1fef6932ecca 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt @@ -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)) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt new file mode 100644 index 000000000000..ef25dabb4c7f --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt @@ -0,0 +1,130 @@ +/* + * 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) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt new file mode 100644 index 000000000000..712d26275000 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt @@ -0,0 +1,79 @@ +/* + * 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.viewmodel + +import android.content.pm.UserInfo +import android.content.pm.UserInfo.FLAG_MAIN +import android.platform.test.annotations.EnableFlags +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +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.flags.Flags.COMMUNAL_SERVICE_ENABLED +import com.android.systemui.flags.fakeFeatureFlagsClassic +import com.android.systemui.kosmos.collectLastValue +import com.android.systemui.kosmos.runTest +import com.android.systemui.kosmos.testScope +import com.android.systemui.kosmos.useUnconfinedTestDispatcher +import com.android.systemui.lifecycle.activateIn +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 org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@EnableFlags(FLAG_GLANCEABLE_HUB_V2) +@RunWith(AndroidJUnit4::class) +class HubOnboardingViewModelTest : SysuiTestCase() { + private val kosmos = testKosmos().useUnconfinedTestDispatcher() + private val underTest: HubOnboardingViewModel by lazy { kosmos.hubOnboardingViewModel } + + @Before + fun setUp() { + kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true) + underTest.activateIn(kosmos.testScope) + } + + @Test + fun onDismissed_setsDismissedTrue() = + kosmos.runTest { + setSelectedUser(MAIN_USER) + + val isHubOnboardingDismissed by + collectLastValue(fakeCommunalPrefsRepository.isHubOnboardingDismissed(MAIN_USER)) + + underTest.onDismissed() + + assertThat(isHubOnboardingDismissed).isTrue() + } + + 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) + } +} diff --git a/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png b/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png Binary files differnew file mode 100644 index 000000000000..bd55e83b377b --- /dev/null +++ b/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 79fa41949b57..866dfe4d8972 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -1359,6 +1359,12 @@ <string name="glanceable_hub_lockscreen_affordance_action_button_label">Settings</string> <!-- Content description for a "show screensaver" button on glanceable hub. [CHAR LIMIT=NONE] --> <string name="accessibility_glanceable_hub_to_dream_button">Show screensaver button</string> + <!-- Title shown in hub onboarding bottom sheet. [CHAR LIMIT=50] --> + <string name="hub_onboarding_bottom_sheet_title">Explore hub mode</string> + <!-- Information about communal hub shown in the onboarding bottom sheet. [CHAR LIMIT=NONE] --> + <string name="hub_onboarding_bottom_sheet_text">Access your favorite widgets and screen savers while charging.</string> + <!-- Hub onboarding bottom sheet action button title. [CHAR LIMIT=NONE] --> + <string name="hub_onboarding_bottom_sheet_action_button">Let\u2019s go</string> <!-- Related to user switcher --><skip/> diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt index 4de39c457f3b..a02bc8b89910 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt @@ -51,6 +51,12 @@ interface CommunalPrefsRepository { /** Save the CTA tile dismissed state for the current user. */ suspend fun setCtaDismissed(user: UserInfo) + + /** Whether hub onboarding has been dismissed. */ + fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean> + + /** Save the hub onboarding dismissed state for the current user. */ + suspend fun setHubOnboardingDismissed(user: UserInfo) } @OptIn(ExperimentalCoroutinesApi::class) @@ -65,9 +71,6 @@ constructor( ) : CommunalPrefsRepository { private val logger by lazy { Logger(logBuffer, TAG) } - override fun isCtaDismissed(user: UserInfo): Flow<Boolean> = - readKeyForUser(user, CTA_DISMISSED_STATE) - /** * Emits an event each time a Backup & Restore restoration job is completed, and once at the * start of collection. @@ -82,18 +85,29 @@ constructor( .onEach { logger.i("Restored state for communal preferences.") } .emitOnStart() + override fun isCtaDismissed(user: UserInfo): Flow<Boolean> = + readKeyForUser(user, CTA_DISMISSED_STATE) + override suspend fun setCtaDismissed(user: UserInfo) = withContext(bgDispatcher) { getSharedPrefsForUser(user).edit().putBoolean(CTA_DISMISSED_STATE, true).apply() logger.i("Dismissed CTA tile") } + override fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean> = + readKeyForUser(user, HUB_ONBOARDING_DISMISSED_STATE) + + override suspend fun setHubOnboardingDismissed(user: UserInfo) = + withContext(bgDispatcher) { + getSharedPrefsForUser(user) + .edit() + .putBoolean(HUB_ONBOARDING_DISMISSED_STATE, true) + .apply() + logger.i("Dismissed hub onboarding") + } + private fun getSharedPrefsForUser(user: UserInfo): SharedPreferences { - return userFileManager.getSharedPreferences( - FILE_NAME, - Context.MODE_PRIVATE, - user.id, - ) + return userFileManager.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE, user.id) } private fun readKeyForUser(user: UserInfo, key: String): Flow<Boolean> { @@ -109,5 +123,6 @@ constructor( const val TAG = "CommunalPrefsRepository" const val FILE_NAME = "communal_hub_prefs" const val CTA_DISMISSED_STATE = "cta_dismissed" + const val HUB_ONBOARDING_DISMISSED_STATE = "hub_onboarding_dismissed" } } diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt index 0b5f40d8041e..76e6cde4ad81 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @SysUISingleton @@ -63,6 +64,24 @@ constructor( suspend fun setCtaDismissed(user: UserInfo = userTracker.userInfo) = repository.setCtaDismissed(user) + val isHubOnboardingDismissed: Flow<Boolean> = + userInteractor.selectedUserInfo + .flatMapLatest { user -> repository.isHubOnboardingDismissed(user) } + .logDiffsForTable( + tableLogBuffer = tableLogBuffer, + columnPrefix = "", + columnName = "isHubOnboardingDismissed", + initialValue = false, + ) + .stateIn( + scope = bgScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + fun setHubOnboardingDismissed(user: UserInfo = userTracker.userInfo) = + bgScope.launch { repository.setHubOnboardingDismissed(user) } + private companion object { const val TAG = "CommunalPrefsInteractor" } diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt new file mode 100644 index 000000000000..26a0a7930b4a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt @@ -0,0 +1,46 @@ +/* + * 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 com.android.systemui.communal.data.repository.CommunalSettingsRepository +import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf +import com.android.systemui.util.kotlin.BooleanFlowOperators.not +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class HubOnboardingInteractor +@Inject +constructor( + communalSceneInteractor: CommunalSceneInteractor, + communalSettingsRepository: CommunalSettingsRepository, + private val communalPrefsInteractor: CommunalPrefsInteractor, +) { + /** Dismiss hub onboarding education. */ + fun setHubOnboardingDismissed() = communalPrefsInteractor.setHubOnboardingDismissed() + + /** Should hub onboarding be shown to the user. */ + val shouldShowHubOnboarding: Flow<Boolean> = + if (communalSettingsRepository.getV2FlagEnabled()) { + allOf( + not(communalPrefsInteractor.isHubOnboardingDismissed), + communalSceneInteractor.isIdleOnCommunal, + ) + } else { + flowOf(false) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt new file mode 100644 index 000000000000..5245800ba3d1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt @@ -0,0 +1,44 @@ +/* + * 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.viewmodel + +import android.annotation.SuppressLint +import com.android.systemui.communal.domain.interactor.HubOnboardingInteractor +import com.android.systemui.lifecycle.ExclusiveActivatable +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope + +class HubOnboardingViewModel +@AssistedInject +constructor(private val hubOnboardingInteractor: HubOnboardingInteractor) : ExclusiveActivatable() { + + val shouldShowHubOnboarding = hubOnboardingInteractor.shouldShowHubOnboarding + + fun onDismissed() { + hubOnboardingInteractor.setHubOnboardingDismissed() + } + + @SuppressLint("MissingPermission") + override suspend fun onActivated(): Nothing = coroutineScope { awaitCancellation() } + + @AssistedFactory + interface Factory { + fun create(): HubOnboardingViewModel + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt index 5e870b19681b..163625747d85 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.map /** Fake implementation of [CommunalPrefsRepository] */ class FakeCommunalPrefsRepository : CommunalPrefsRepository { private val _isCtaDismissed = MutableStateFlow<Set<UserInfo>>(emptySet()) + private val _isHubOnboardingismissed = MutableStateFlow<Set<UserInfo>>(emptySet()) override fun isCtaDismissed(user: UserInfo): Flow<Boolean> = _isCtaDismissed.map { it.contains(user) } @@ -32,4 +33,12 @@ class FakeCommunalPrefsRepository : CommunalPrefsRepository { override suspend fun setCtaDismissed(user: UserInfo) { _isCtaDismissed.value = _isCtaDismissed.value.toMutableSet().apply { add(user) } } + + override fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean> = + _isHubOnboardingismissed.map { it.contains(user) } + + override suspend fun setHubOnboardingDismissed(user: UserInfo) { + _isHubOnboardingismissed.value = + _isHubOnboardingismissed.value.toMutableSet().apply { add(user) } + } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt new file mode 100644 index 000000000000..9db4e4f4b8f2 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt @@ -0,0 +1,29 @@ +/* + * 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 com.android.systemui.communal.data.repository.communalSettingsRepository +import com.android.systemui.kosmos.Kosmos + +val Kosmos.hubOnboardingInteractor: HubOnboardingInteractor by + Kosmos.Fixture { + HubOnboardingInteractor( + communalSceneInteractor = communalSceneInteractor, + communalSettingsRepository = communalSettingsRepository, + communalPrefsInteractor = communalPrefsInteractor, + ) + } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt new file mode 100644 index 000000000000..9cdaaf4b88f8 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt @@ -0,0 +1,23 @@ +/* + * 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.viewmodel + +import com.android.systemui.communal.domain.interactor.hubOnboardingInteractor +import com.android.systemui.kosmos.Kosmos + +val Kosmos.hubOnboardingViewModel by + Kosmos.Fixture { HubOnboardingViewModel(hubOnboardingInteractor = hubOnboardingInteractor) } |