Add modes tiles to dialog
Bug: 346519570
Test: manually checked the dialog + ModesDialogViewModelTest
Flag: android.app.modes_ui
Change-Id: I4ab669ac9e204d6f51388e1b032c5c20a28dc735
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index 1871873..cf82d62 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -539,6 +539,7 @@
"androidx.preference_preference",
"androidx.appcompat_appcompat",
"androidx.concurrent_concurrent-futures",
+ "androidx.concurrent_concurrent-futures-ktx",
"androidx.mediarouter_mediarouter",
"androidx.palette_palette",
"androidx.legacy_legacy-preference-v14",
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModelTest.kt
new file mode 100644
index 0000000..0d6f9a3
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModelTest.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.statusbar.policy.ui.dialog.viewmodel
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.notification.modes.TestModeBuilder
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.statusbar.policy.data.repository.fakeZenModeRepository
+import com.android.systemui.statusbar.policy.domain.interactor.zenModeInteractor
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class ModesDialogViewModelTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+ private val testScope = kosmos.testScope
+ val repository = kosmos.fakeZenModeRepository
+ val interactor = kosmos.zenModeInteractor
+
+ val underTest = ModesDialogViewModel(context, interactor, kosmos.testDispatcher)
+
+ @Test
+ fun tiles_filtersOutDisabledModes() =
+ testScope.runTest {
+ val tiles by collectLastValue(underTest.tiles)
+
+ repository.addModes(
+ listOf(
+ TestModeBuilder().setName("Disabled").setEnabled(false).build(),
+ TestModeBuilder.MANUAL_DND,
+ TestModeBuilder()
+ .setName("Enabled")
+ .setEnabled(true)
+ .setManualInvocationAllowed(true)
+ .build(),
+ TestModeBuilder()
+ .setName("Disabled with manual")
+ .setEnabled(false)
+ .setManualInvocationAllowed(true)
+ .build(),
+ ))
+ runCurrent()
+
+ assertThat(tiles?.size).isEqualTo(2)
+ with(tiles?.elementAt(0)!!) {
+ assertThat(this.text).isEqualTo("Manual DND")
+ assertThat(this.subtext).isEqualTo("On")
+ assertThat(this.enabled).isEqualTo(true)
+ }
+ with(tiles?.elementAt(1)!!) {
+ assertThat(this.text).isEqualTo("Enabled")
+ assertThat(this.subtext).isEqualTo("Off")
+ assertThat(this.enabled).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun tiles_filtersOutInactiveModesWithoutManualInvocation() =
+ testScope.runTest {
+ val tiles by collectLastValue(underTest.tiles)
+
+ repository.addModes(
+ listOf(
+ TestModeBuilder()
+ .setName("Active without manual")
+ .setActive(true)
+ .setManualInvocationAllowed(false)
+ .build(),
+ TestModeBuilder()
+ .setName("Active with manual")
+ .setTriggerDescription("trigger description")
+ .setActive(true)
+ .setManualInvocationAllowed(true)
+ .build(),
+ TestModeBuilder()
+ .setName("Inactive with manual")
+ .setActive(false)
+ .setManualInvocationAllowed(true)
+ .build(),
+ TestModeBuilder()
+ .setName("Inactive without manual")
+ .setActive(false)
+ .setManualInvocationAllowed(false)
+ .build(),
+ ))
+ runCurrent()
+
+ assertThat(tiles?.size).isEqualTo(3)
+ with(tiles?.elementAt(0)!!) {
+ assertThat(this.text).isEqualTo("Active without manual")
+ assertThat(this.subtext).isEqualTo("On")
+ assertThat(this.enabled).isEqualTo(true)
+ }
+ with(tiles?.elementAt(1)!!) {
+ assertThat(this.text).isEqualTo("Active with manual")
+ assertThat(this.subtext).isEqualTo("trigger description")
+ assertThat(this.enabled).isEqualTo(true)
+ }
+ with(tiles?.elementAt(2)!!) {
+ assertThat(this.text).isEqualTo("Inactive with manual")
+ assertThat(this.subtext).isEqualTo("Off")
+ assertThat(this.enabled).isEqualTo(false)
+ }
+ }
+}
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 7caa2c6..1c3eba9 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1093,6 +1093,12 @@
<!-- Priority modes dialog settings shortcut button [CHAR LIMIT=15] -->
<string name="zen_modes_dialog_settings">Settings</string>
+ <!-- Priority modes: label for an active mode [CHAR LIMIT=35] -->
+ <string name="zen_mode_on">On</string>
+
+ <!-- Priority modes: label for an inactive mode [CHAR LIMIT=35] -->
+ <string name="zen_mode_off">Off</string>
+
<!-- Zen mode: Priority only introduction message on first use -->
<string name="zen_priority_introduction">You won\'t be disturbed by sounds and vibrations, except from alarms, reminders, events, and callers you specify. You\'ll still hear anything you choose to play including music, videos, and games.</string>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
index e4d0668..8907682 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractor.kt
@@ -16,8 +16,13 @@
package com.android.systemui.statusbar.policy.domain.interactor
+import android.content.Context
import android.provider.Settings
+import androidx.concurrent.futures.await
import com.android.settingslib.notification.data.repository.ZenModeRepository
+import com.android.settingslib.notification.modes.ZenIconLoader
+import com.android.settingslib.notification.modes.ZenMode
+import com.android.systemui.common.shared.model.Icon
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -29,6 +34,8 @@
* (or Do Not Disturb/DND Mode).
*/
class ZenModeInteractor @Inject constructor(repository: ZenModeRepository) {
+ private val iconLoader: ZenIconLoader = ZenIconLoader.getInstance()
+
val isZenModeEnabled: Flow<Boolean> =
repository.globalZenMode
.map {
@@ -52,4 +59,10 @@
}
}
.distinctUntilChanged()
+
+ val modes: Flow<List<ZenMode>> = repository.modes
+
+ suspend fun getModeIcon(mode: ZenMode, context: Context): Icon {
+ return Icon.Loaded(mode.getIcon(context, iconLoader).await(), contentDescription = null)
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/ModesDialogDelegate.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/ModesDialogDelegate.kt
index 6db1eac..2b094d6 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/ModesDialogDelegate.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/ModesDialogDelegate.kt
@@ -29,6 +29,8 @@
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.statusbar.phone.create
+import com.android.systemui.statusbar.policy.ui.dialog.composable.ModeTileGrid
+import com.android.systemui.statusbar.policy.ui.dialog.viewmodel.ModesDialogViewModel
import javax.inject.Inject
class ModesDialogDelegate
@@ -37,12 +39,13 @@
private val sysuiDialogFactory: SystemUIDialogFactory,
private val dialogTransitionAnimator: DialogTransitionAnimator,
private val activityStarter: ActivityStarter,
+ private val viewModel: ModesDialogViewModel,
) : SystemUIDialog.Delegate {
override fun createDialog(): SystemUIDialog {
return sysuiDialogFactory.create { dialog ->
AlertDialogContent(
title = { Text(stringResource(R.string.zen_modes_dialog_title)) },
- content = { Text("Under construction") },
+ content = { ModeTileGrid(viewModel) },
neutralButton = {
PlatformOutlinedButton(
onClick = {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTile.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTile.kt
new file mode 100644
index 0000000..91bfdff
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTile.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.statusbar.policy.ui.dialog.composable
+
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.android.systemui.common.ui.compose.Icon
+import com.android.systemui.statusbar.policy.ui.dialog.viewmodel.ModeTileViewModel
+
+@Composable
+fun ModeTile(viewModel: ModeTileViewModel) {
+ val tileColor =
+ if (viewModel.enabled) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant
+ val contentColor =
+ if (viewModel.enabled) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurfaceVariant
+
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Surface(
+ color = tileColor,
+ shape = RoundedCornerShape(16.dp),
+ modifier =
+ Modifier.combinedClickable(
+ onClick = viewModel.onClick,
+ onLongClick = viewModel.onLongClick
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement =
+ Arrangement.spacedBy(
+ space = 10.dp,
+ alignment = Alignment.Start,
+ ),
+ ) {
+ Icon(icon = viewModel.icon, modifier = Modifier.size(24.dp))
+ Column {
+ Text(
+ viewModel.text,
+ fontWeight = FontWeight.W500,
+ modifier = Modifier.tileMarquee()
+ )
+ Text(
+ viewModel.subtext,
+ fontWeight = FontWeight.W400,
+ modifier = Modifier.tileMarquee()
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun Modifier.tileMarquee(): Modifier {
+ return this.basicMarquee(
+ iterations = 1,
+ initialDelayMillis = 200,
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTileGrid.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTileGrid.kt
new file mode 100644
index 0000000..73d361f6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/composable/ModeTileGrid.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.statusbar.policy.ui.dialog.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.systemui.statusbar.policy.ui.dialog.viewmodel.ModesDialogViewModel
+
+@Composable
+fun ModeTileGrid(viewModel: ModesDialogViewModel) {
+ val tiles by viewModel.tiles.collectAsStateWithLifecycle(initialValue = emptyList())
+
+ // TODO(b/346519570): Handle what happens when we have more than a few modes.
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ modifier = Modifier.padding(8.dp).fillMaxWidth().heightIn(max = 300.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(
+ tiles.size,
+ key = { index -> tiles[index].id },
+ ) { index ->
+ ModeTile(viewModel = tiles[index])
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModeTileViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModeTileViewModel.kt
new file mode 100644
index 0000000..5bd26cc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModeTileViewModel.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.statusbar.policy.ui.dialog.viewmodel
+
+import com.android.systemui.common.shared.model.Icon
+
+/**
+ * Viewmodel for a tile representing a single priority ("zen") mode, for use within the modes
+ * dialog. Not to be confused with ModesTile, which is the Quick Settings tile that opens the
+ * dialog.
+ */
+data class ModeTileViewModel(
+ val id: String,
+ val icon: Icon,
+ val text: String,
+ val subtext: String,
+ val enabled: Boolean,
+ val contentDescription: String,
+ val onClick: () -> Unit,
+ val onLongClick: () -> Unit,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModel.kt
new file mode 100644
index 0000000..b632365
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ui/dialog/viewmodel/ModesDialogViewModel.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.statusbar.policy.ui.dialog.viewmodel
+
+import android.content.Context
+import com.android.settingslib.notification.modes.ZenMode
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.policy.domain.interactor.ZenModeInteractor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+
+/**
+ * Viewmodel for the priority ("zen") modes dialog that can be opened from quick settings. It allows
+ * the user to quickly toggle modes.
+ */
+@SysUISingleton
+class ModesDialogViewModel
+@Inject
+constructor(
+ val context: Context,
+ zenModeInteractor: ZenModeInteractor,
+ @Background val bgDispatcher: CoroutineDispatcher,
+) {
+ // Modes that should be displayed in the dialog
+ // TODO(b/346519570): Include modes that have not been set up yet.
+ private val visibleModes: Flow<List<ZenMode>> =
+ zenModeInteractor.modes.map {
+ it.filter { mode ->
+ mode.rule.isEnabled && (mode.isActive || mode.rule.isManualInvocationAllowed)
+ }
+ }
+
+ val tiles: Flow<List<ModeTileViewModel>> =
+ visibleModes
+ .map { modesList ->
+ modesList.map { mode ->
+ ModeTileViewModel(
+ id = mode.id,
+ icon = zenModeInteractor.getModeIcon(mode, context),
+ text = mode.rule.name,
+ subtext = getTileSubtext(mode),
+ enabled = mode.isActive,
+ // TODO(b/346519570): This should be some combination of the above, e.g.
+ // "ON: Do Not Disturb, Until Mon 08:09"; see DndTile.
+ contentDescription = "",
+ onClick = {
+ // TODO(b/346519570): Toggle mode.
+ },
+ onLongClick = {
+ // TODO(b/346519570): Open settings page for mode.
+ }
+ )
+ }
+ }
+ .flowOn(bgDispatcher)
+
+ private fun getTileSubtext(mode: ZenMode): String {
+ // TODO(b/346519570): Use ZenModeConfig.getDescription for manual DND
+ val on = context.resources.getString(R.string.zen_mode_on)
+ val off = context.resources.getString(R.string.zen_mode_off)
+ return mode.rule.triggerDescription ?: if (mode.isActive) on else off
+ }
+}