Loading packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt +42 −1 Original line number Diff line number Diff line Loading @@ -45,6 +45,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.Widgets import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors Loading Loading @@ -76,10 +77,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Popup import com.android.compose.theme.LocalAndroidColorScheme import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.shared.model.CommunalContentSize Loading @@ -97,6 +100,8 @@ fun CommunalHub( onEditDone: (() -> Unit)? = null, ) { val communalContent by viewModel.communalContent.collectAsState(initial = emptyList()) val isPopupOnDismissCtaShowing by viewModel.isPopupOnDismissCtaShowing.collectAsState(initial = false) var removeButtonCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } var toolbarSize: IntSize? by remember { mutableStateOf(null) } var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Loading Loading @@ -133,6 +138,10 @@ fun CommunalHub( } } if (isPopupOnDismissCtaShowing) { PopupOnDismissCtaTile(viewModel::onHidePopupAfterDismissCta) } // This spacer covers the edge of the LazyHorizontalGrid and prevents it from receiving // touches, so that the SceneTransitionLayout can intercept the touches and allow an edge // swipe back to the blank scene. Loading Loading @@ -318,9 +327,41 @@ private fun Toolbar( } } @Composable private fun PopupOnDismissCtaTile(onHidePopupAfterDismissCta: () -> Unit) { Popup( alignment = Alignment.TopCenter, offset = IntOffset(0, 40), onDismissRequest = onHidePopupAfterDismissCta ) { val colors = LocalAndroidColorScheme.current Row( modifier = Modifier.height(56.dp) .background(colors.secondary, RoundedCornerShape(50.dp)) .padding(16.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Outlined.TouchApp, contentDescription = stringResource(R.string.popup_on_dismiss_cta_tile_text), tint = colors.onSecondary, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.popup_on_dismiss_cta_tile_text), style = MaterialTheme.typography.titleSmall, color = colors.onSecondary, ) } } } @Composable private fun RemoveButtonContent(spacerModifier: Modifier) { Icon(Icons.Outlined.Delete, stringResource(R.string.button_to_open_widget_editor)) Icon(Icons.Outlined.Delete, stringResource(R.string.button_to_remove_widget)) Spacer(spacerModifier) Text( text = stringResource(R.string.button_to_remove_widget), Loading packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt +45 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import com.android.systemui.communal.domain.interactor.CommunalInteractorFactory import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.shared.model.CommunalWidgetContentModel import com.android.systemui.communal.ui.viewmodel.CommunalViewModel import com.android.systemui.communal.ui.viewmodel.CommunalViewModel.Companion.POPUP_AUTO_HIDE_TIMEOUT_MS import com.android.systemui.communal.widgets.WidgetInteractionHandler import com.android.systemui.coroutines.collectLastValue import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository Loading @@ -41,7 +42,9 @@ import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import javax.inject.Provider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test Loading @@ -50,6 +53,7 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class CommunalViewModelTest : SysuiTestCase() { Loading Loading @@ -84,6 +88,7 @@ class CommunalViewModelTest : SysuiTestCase() { underTest = CommunalViewModel( testScope, withDeps.communalInteractor, WidgetInteractionHandler(mock()), withDeps.tutorialInteractor, Loading Loading @@ -159,4 +164,44 @@ class CommunalViewModelTest : SysuiTestCase() { assertThat(communalContent?.get(4)) .isInstanceOf(CommunalContentModel.CtaTileInViewMode::class.java) } @Test fun dismissCta_hidesCtaTileAndShowsPopup_thenHidesPopupAfterTimeout() = testScope.runTest { tutorialRepository.setTutorialSettingState(Settings.Secure.HUB_MODE_TUTORIAL_COMPLETED) communalRepository.setCtaTileInViewModeVisibility(true) val communalContent by collectLastValue(underTest.communalContent) val isPopupOnDismissCtaShowing by collectLastValue(underTest.isPopupOnDismissCtaShowing) assertThat(communalContent?.size).isEqualTo(1) assertThat(communalContent?.get(0)) .isInstanceOf(CommunalContentModel.CtaTileInViewMode::class.java) underTest.onDismissCtaTile() // hide CTA tile and show the popup assertThat(communalContent).isEmpty() assertThat(isPopupOnDismissCtaShowing).isEqualTo(true) // hide popup after time elapsed advanceTimeBy(POPUP_AUTO_HIDE_TIMEOUT_MS) assertThat(isPopupOnDismissCtaShowing).isEqualTo(false) } @Test fun popup_onDismiss_hidesImmediately() = testScope.runTest { tutorialRepository.setTutorialSettingState(Settings.Secure.HUB_MODE_TUTORIAL_COMPLETED) communalRepository.setCtaTileInViewModeVisibility(true) val isPopupOnDismissCtaShowing by collectLastValue(underTest.isPopupOnDismissCtaShowing) underTest.onDismissCtaTile() assertThat(isPopupOnDismissCtaShowing).isEqualTo(true) // dismiss the popup directly underTest.onHidePopupAfterDismissCta() assertThat(isPopupOnDismissCtaShowing).isEqualTo(false) } } packages/SystemUI/res/values/strings.xml +2 −0 Original line number Diff line number Diff line Loading @@ -1087,6 +1087,8 @@ <string name="cta_label_to_edit_widget">Add, remove, and reorder your widgets in this space</string> <!-- Label for CTA tile that opens widget picker on click in edit mode [CHAR LIMIT=50] --> <string name="cta_label_to_open_widget_picker">Add more widgets</string> <!-- Text for the popup to be displayed after dismissing the CTA tile. [CHAR LIMIT=50] --> <string name="popup_on_dismiss_cta_tile_text">Long press to customize widgets</string> <!-- Description for the button that removes a widget on click. [CHAR LIMIT=50] --> <string name="button_to_remove_widget">Remove</string> <!-- Text for the button that launches the hub mode widget picker. [CHAR LIMIT=50] --> Loading packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt +7 −0 Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ import com.android.systemui.shade.ShadeViewController import javax.inject.Provider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf /** The base view model for the communal hub. */ abstract class BaseCommunalViewModel( Loading Loading @@ -96,6 +97,12 @@ abstract class BaseCommunalViewModel( /** Whether in edit mode for the communal hub. */ open val isEditMode = false /** Whether the popup message triggered by dismissing the CTA tile is showing. */ open val isPopupOnDismissCtaShowing: Flow<Boolean> = flowOf(false) /** Hide the popup message triggered by dismissing the CTA tile. */ open fun onHidePopupAfterDismissCta() {} /** Called as the UI requests deleting a widget. */ open fun onDeleteWidget(id: Int) {} Loading packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalViewModel.kt +45 −1 Original line number Diff line number Diff line Loading @@ -23,23 +23,31 @@ import com.android.systemui.communal.domain.interactor.CommunalTutorialInteracto import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.widgets.WidgetInteractionHandler import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.media.controls.ui.MediaHost import com.android.systemui.media.dagger.MediaModule import com.android.systemui.shade.ShadeViewController import javax.inject.Inject import javax.inject.Named import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** The default view model used for showing the communal hub. */ @SysUISingleton class CommunalViewModel @Inject constructor( @Application private val scope: CoroutineScope, private val communalInteractor: CommunalInteractor, private val interactionHandler: WidgetInteractionHandler, tutorialInteractor: CommunalTutorialInteractor, Loading @@ -63,9 +71,45 @@ constructor( } } private val _isPopupOnDismissCtaShowing: MutableStateFlow<Boolean> = MutableStateFlow(false) override val isPopupOnDismissCtaShowing: Flow<Boolean> = _isPopupOnDismissCtaShowing.asStateFlow() override fun onOpenWidgetEditor() = communalInteractor.showWidgetEditor() override fun onDismissCtaTile() = communalInteractor.dismissCtaTile() override fun onDismissCtaTile() { communalInteractor.dismissCtaTile() setPopupOnDismissCtaVisibility(true) schedulePopupHiding() } override fun getInteractionHandler(): RemoteViews.InteractionHandler = interactionHandler override fun onHidePopupAfterDismissCta() { cancelDelayedPopupHiding() setPopupOnDismissCtaVisibility(false) } private fun setPopupOnDismissCtaVisibility(isVisible: Boolean) { _isPopupOnDismissCtaShowing.value = isVisible } private var delayedHidePopupJob: Job? = null private fun schedulePopupHiding() { cancelDelayedPopupHiding() delayedHidePopupJob = scope.launch { delay(POPUP_AUTO_HIDE_TIMEOUT_MS) onHidePopupAfterDismissCta() } } private fun cancelDelayedPopupHiding() { delayedHidePopupJob?.cancel() delayedHidePopupJob = null } companion object { const val POPUP_AUTO_HIDE_TIMEOUT_MS = 12000L } } Loading
packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalHub.kt +42 −1 Original line number Diff line number Diff line Loading @@ -45,6 +45,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.Widgets import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors Loading Loading @@ -76,10 +77,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Popup import com.android.compose.theme.LocalAndroidColorScheme import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.shared.model.CommunalContentSize Loading @@ -97,6 +100,8 @@ fun CommunalHub( onEditDone: (() -> Unit)? = null, ) { val communalContent by viewModel.communalContent.collectAsState(initial = emptyList()) val isPopupOnDismissCtaShowing by viewModel.isPopupOnDismissCtaShowing.collectAsState(initial = false) var removeButtonCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } var toolbarSize: IntSize? by remember { mutableStateOf(null) } var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) } Loading Loading @@ -133,6 +138,10 @@ fun CommunalHub( } } if (isPopupOnDismissCtaShowing) { PopupOnDismissCtaTile(viewModel::onHidePopupAfterDismissCta) } // This spacer covers the edge of the LazyHorizontalGrid and prevents it from receiving // touches, so that the SceneTransitionLayout can intercept the touches and allow an edge // swipe back to the blank scene. Loading Loading @@ -318,9 +327,41 @@ private fun Toolbar( } } @Composable private fun PopupOnDismissCtaTile(onHidePopupAfterDismissCta: () -> Unit) { Popup( alignment = Alignment.TopCenter, offset = IntOffset(0, 40), onDismissRequest = onHidePopupAfterDismissCta ) { val colors = LocalAndroidColorScheme.current Row( modifier = Modifier.height(56.dp) .background(colors.secondary, RoundedCornerShape(50.dp)) .padding(16.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Outlined.TouchApp, contentDescription = stringResource(R.string.popup_on_dismiss_cta_tile_text), tint = colors.onSecondary, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.popup_on_dismiss_cta_tile_text), style = MaterialTheme.typography.titleSmall, color = colors.onSecondary, ) } } } @Composable private fun RemoveButtonContent(spacerModifier: Modifier) { Icon(Icons.Outlined.Delete, stringResource(R.string.button_to_open_widget_editor)) Icon(Icons.Outlined.Delete, stringResource(R.string.button_to_remove_widget)) Spacer(spacerModifier) Text( text = stringResource(R.string.button_to_remove_widget), Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/communal/view/viewmodel/CommunalViewModelTest.kt +45 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import com.android.systemui.communal.domain.interactor.CommunalInteractorFactory import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.shared.model.CommunalWidgetContentModel import com.android.systemui.communal.ui.viewmodel.CommunalViewModel import com.android.systemui.communal.ui.viewmodel.CommunalViewModel.Companion.POPUP_AUTO_HIDE_TIMEOUT_MS import com.android.systemui.communal.widgets.WidgetInteractionHandler import com.android.systemui.coroutines.collectLastValue import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository Loading @@ -41,7 +42,9 @@ import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import javax.inject.Provider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test Loading @@ -50,6 +53,7 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class CommunalViewModelTest : SysuiTestCase() { Loading Loading @@ -84,6 +88,7 @@ class CommunalViewModelTest : SysuiTestCase() { underTest = CommunalViewModel( testScope, withDeps.communalInteractor, WidgetInteractionHandler(mock()), withDeps.tutorialInteractor, Loading Loading @@ -159,4 +164,44 @@ class CommunalViewModelTest : SysuiTestCase() { assertThat(communalContent?.get(4)) .isInstanceOf(CommunalContentModel.CtaTileInViewMode::class.java) } @Test fun dismissCta_hidesCtaTileAndShowsPopup_thenHidesPopupAfterTimeout() = testScope.runTest { tutorialRepository.setTutorialSettingState(Settings.Secure.HUB_MODE_TUTORIAL_COMPLETED) communalRepository.setCtaTileInViewModeVisibility(true) val communalContent by collectLastValue(underTest.communalContent) val isPopupOnDismissCtaShowing by collectLastValue(underTest.isPopupOnDismissCtaShowing) assertThat(communalContent?.size).isEqualTo(1) assertThat(communalContent?.get(0)) .isInstanceOf(CommunalContentModel.CtaTileInViewMode::class.java) underTest.onDismissCtaTile() // hide CTA tile and show the popup assertThat(communalContent).isEmpty() assertThat(isPopupOnDismissCtaShowing).isEqualTo(true) // hide popup after time elapsed advanceTimeBy(POPUP_AUTO_HIDE_TIMEOUT_MS) assertThat(isPopupOnDismissCtaShowing).isEqualTo(false) } @Test fun popup_onDismiss_hidesImmediately() = testScope.runTest { tutorialRepository.setTutorialSettingState(Settings.Secure.HUB_MODE_TUTORIAL_COMPLETED) communalRepository.setCtaTileInViewModeVisibility(true) val isPopupOnDismissCtaShowing by collectLastValue(underTest.isPopupOnDismissCtaShowing) underTest.onDismissCtaTile() assertThat(isPopupOnDismissCtaShowing).isEqualTo(true) // dismiss the popup directly underTest.onHidePopupAfterDismissCta() assertThat(isPopupOnDismissCtaShowing).isEqualTo(false) } }
packages/SystemUI/res/values/strings.xml +2 −0 Original line number Diff line number Diff line Loading @@ -1087,6 +1087,8 @@ <string name="cta_label_to_edit_widget">Add, remove, and reorder your widgets in this space</string> <!-- Label for CTA tile that opens widget picker on click in edit mode [CHAR LIMIT=50] --> <string name="cta_label_to_open_widget_picker">Add more widgets</string> <!-- Text for the popup to be displayed after dismissing the CTA tile. [CHAR LIMIT=50] --> <string name="popup_on_dismiss_cta_tile_text">Long press to customize widgets</string> <!-- Description for the button that removes a widget on click. [CHAR LIMIT=50] --> <string name="button_to_remove_widget">Remove</string> <!-- Text for the button that launches the hub mode widget picker. [CHAR LIMIT=50] --> Loading
packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt +7 −0 Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ import com.android.systemui.shade.ShadeViewController import javax.inject.Provider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf /** The base view model for the communal hub. */ abstract class BaseCommunalViewModel( Loading Loading @@ -96,6 +97,12 @@ abstract class BaseCommunalViewModel( /** Whether in edit mode for the communal hub. */ open val isEditMode = false /** Whether the popup message triggered by dismissing the CTA tile is showing. */ open val isPopupOnDismissCtaShowing: Flow<Boolean> = flowOf(false) /** Hide the popup message triggered by dismissing the CTA tile. */ open fun onHidePopupAfterDismissCta() {} /** Called as the UI requests deleting a widget. */ open fun onDeleteWidget(id: Int) {} Loading
packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalViewModel.kt +45 −1 Original line number Diff line number Diff line Loading @@ -23,23 +23,31 @@ import com.android.systemui.communal.domain.interactor.CommunalTutorialInteracto import com.android.systemui.communal.domain.model.CommunalContentModel import com.android.systemui.communal.widgets.WidgetInteractionHandler import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.media.controls.ui.MediaHost import com.android.systemui.media.dagger.MediaModule import com.android.systemui.shade.ShadeViewController import javax.inject.Inject import javax.inject.Named import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** The default view model used for showing the communal hub. */ @SysUISingleton class CommunalViewModel @Inject constructor( @Application private val scope: CoroutineScope, private val communalInteractor: CommunalInteractor, private val interactionHandler: WidgetInteractionHandler, tutorialInteractor: CommunalTutorialInteractor, Loading @@ -63,9 +71,45 @@ constructor( } } private val _isPopupOnDismissCtaShowing: MutableStateFlow<Boolean> = MutableStateFlow(false) override val isPopupOnDismissCtaShowing: Flow<Boolean> = _isPopupOnDismissCtaShowing.asStateFlow() override fun onOpenWidgetEditor() = communalInteractor.showWidgetEditor() override fun onDismissCtaTile() = communalInteractor.dismissCtaTile() override fun onDismissCtaTile() { communalInteractor.dismissCtaTile() setPopupOnDismissCtaVisibility(true) schedulePopupHiding() } override fun getInteractionHandler(): RemoteViews.InteractionHandler = interactionHandler override fun onHidePopupAfterDismissCta() { cancelDelayedPopupHiding() setPopupOnDismissCtaVisibility(false) } private fun setPopupOnDismissCtaVisibility(isVisible: Boolean) { _isPopupOnDismissCtaShowing.value = isVisible } private var delayedHidePopupJob: Job? = null private fun schedulePopupHiding() { cancelDelayedPopupHiding() delayedHidePopupJob = scope.launch { delay(POPUP_AUTO_HIDE_TIMEOUT_MS) onHidePopupAfterDismissCta() } } private fun cancelDelayedPopupHiding() { delayedHidePopupJob?.cancel() delayedHidePopupJob = null } companion object { const val POPUP_AUTO_HIDE_TIMEOUT_MS = 12000L } }