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
+    }
+}