Merge "Migrate UiModeNightTile" into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileDataInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileDataInteractorTest.kt
new file mode 100644
index 0000000..7497ebd
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileDataInteractorTest.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain
+
+import android.app.UiModeManager
+import android.content.res.Configuration
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import android.os.UserHandle
+import android.testing.LeakCheck
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
+import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.interactor.UiModeNightTileDataInteractor
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
+import com.android.systemui.statusbar.phone.ConfigurationControllerImpl
+import com.android.systemui.statusbar.policy.ConfigurationController
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.time.DateFormatUtil
+import com.android.systemui.utils.leaks.FakeBatteryController
+import com.android.systemui.utils.leaks.FakeLocationController
+import com.google.common.truth.Truth.assertThat
+import java.time.LocalTime
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class UiModeNightTileDataInteractorTest : SysuiTestCase() {
+    private val configurationController: ConfigurationController =
+        ConfigurationControllerImpl(context)
+    private val batteryController = FakeBatteryController(LeakCheck())
+    private val locationController = FakeLocationController(LeakCheck())
+
+    private lateinit var underTest: UiModeNightTileDataInteractor
+
+    @Mock private lateinit var uiModeManager: UiModeManager
+    @Mock private lateinit var dateFormatUtil: DateFormatUtil
+
+    @Before
+    fun setup() {
+        uiModeManager = mock<UiModeManager>()
+        dateFormatUtil = mock<DateFormatUtil>()
+
+        whenever(uiModeManager.customNightModeStart).thenReturn(LocalTime.MIN)
+        whenever(uiModeManager.customNightModeEnd).thenReturn(LocalTime.MAX)
+
+        underTest =
+            UiModeNightTileDataInteractor(
+                context,
+                configurationController,
+                uiModeManager,
+                batteryController,
+                locationController,
+                dateFormatUtil
+            )
+    }
+
+    @Test
+    fun collectTileDataReadsUiModeManagerNightMode() = runTest {
+        val expectedNightMode = Configuration.UI_MODE_NIGHT_UNDEFINED
+        whenever(uiModeManager.nightMode).thenReturn(expectedNightMode)
+
+        val model by
+            collectLastValue(
+                underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest))
+            )
+        runCurrent()
+
+        assertThat(model).isNotNull()
+        val actualNightMode = model?.uiMode
+        assertThat(actualNightMode).isEqualTo(expectedNightMode)
+    }
+
+    @Test
+    fun collectTileDataReadsUiModeManagerNightModeCustomTypeAndTimes() = runTest {
+        collectLastValue(underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest)))
+
+        runCurrent()
+
+        verify(uiModeManager).nightMode
+        verify(uiModeManager).nightModeCustomType
+        verify(uiModeManager).customNightModeStart
+        verify(uiModeManager).customNightModeEnd
+    }
+
+    /** Here, available refers to the tile showing up, not the tile being clickable. */
+    @Test
+    fun isAvailableRegardlessOfPowerSaveModeOn() = runTest {
+        batteryController.setPowerSaveMode(true)
+
+        runCurrent()
+        val availability by collectLastValue(underTest.availability(TEST_USER))
+
+        assertThat(availability).isTrue()
+    }
+
+    @Test
+    fun dataMatchesConfigurationController() = runTest {
+        setUiMode(UI_MODE_NIGHT_NO)
+        val flowValues: List<UiModeNightTileModel> by
+            collectValues(underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest)))
+
+        runCurrent()
+        setUiMode(UI_MODE_NIGHT_YES)
+        runCurrent()
+        setUiMode(UI_MODE_NIGHT_NO)
+        runCurrent()
+
+        assertThat(flowValues.size).isEqualTo(3)
+        assertThat(flowValues.map { it.isNightMode }).containsExactly(false, true, false).inOrder()
+    }
+
+    @Test
+    fun dataMatchesBatteryController() = runTest {
+        batteryController.setPowerSaveMode(false)
+        val flowValues: List<UiModeNightTileModel> by
+            collectValues(underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest)))
+
+        runCurrent()
+        batteryController.setPowerSaveMode(true)
+        runCurrent()
+        batteryController.setPowerSaveMode(false)
+        runCurrent()
+
+        assertThat(flowValues.size).isEqualTo(3)
+        assertThat(flowValues.map { it.isPowerSave }).containsExactly(false, true, false).inOrder()
+    }
+
+    @Test
+    fun dataMatchesLocationController() = runTest {
+        locationController.setLocationEnabled(false)
+        val flowValues: List<UiModeNightTileModel> by
+            collectValues(underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest)))
+
+        runCurrent()
+        locationController.setLocationEnabled(true)
+        runCurrent()
+        locationController.setLocationEnabled(false)
+        runCurrent()
+
+        assertThat(flowValues.size).isEqualTo(3)
+        assertThat(flowValues.map { it.isLocationEnabled })
+            .containsExactly(false, true, false)
+            .inOrder()
+    }
+
+    @Test
+    fun collectTileDataReads24HourFormatFromDateTimeUtil() = runTest {
+        collectLastValue(underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest)))
+        runCurrent()
+
+        verify(dateFormatUtil).is24HourFormat
+    }
+
+    /**
+     * Use this method to trigger [ConfigurationController.ConfigurationListener.onUiModeChanged]
+     */
+    private fun setUiMode(uiMode: Int) {
+        val config = context.resources.configuration
+        val newConfig = Configuration(config)
+        newConfig.uiMode = uiMode
+
+        /** [underTest] will see this config the next time it creates a model */
+        context.orCreateTestableResources.overrideConfiguration(newConfig)
+
+        /** Trigger updateUiMode callbacks */
+        configurationController.onConfigurationChanged(newConfig)
+    }
+
+    private companion object {
+        val TEST_USER = UserHandle.of(1)!!
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapperTest.kt
new file mode 100644
index 0000000..87f5009
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapperTest.kt
@@ -0,0 +1,481 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain
+
+import android.app.UiModeManager
+import android.text.TextUtils
+import android.view.View
+import android.widget.Switch
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject
+import com.android.systemui.qs.tiles.impl.uimodenight.UiModeNightTileModelHelper.createModel
+import com.android.systemui.qs.tiles.impl.uimodenight.qsUiModeNightTileConfig
+import com.android.systemui.qs.tiles.viewmodel.QSTileState
+import com.android.systemui.res.R
+import kotlin.reflect.KClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class UiModeNightTileMapperTest : SysuiTestCase() {
+    private val kosmos = Kosmos()
+    private val qsTileConfig = kosmos.qsUiModeNightTileConfig
+
+    private val mapper by lazy {
+        UiModeNightTileMapper(context.orCreateTestableResources.resources)
+    }
+
+    private fun createUiNightModeTileState(
+        iconRes: Int = R.drawable.qs_light_dark_theme_icon_off,
+        label: CharSequence = context.getString(R.string.quick_settings_ui_mode_night_label),
+        activationState: QSTileState.ActivationState = QSTileState.ActivationState.INACTIVE,
+        secondaryLabel: CharSequence? = null,
+        supportedActions: Set<QSTileState.UserAction> =
+            if (activationState == QSTileState.ActivationState.UNAVAILABLE)
+                setOf(QSTileState.UserAction.LONG_CLICK)
+            else setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK),
+        contentDescription: CharSequence? = null,
+        stateDescription: CharSequence? = null,
+        sideViewIcon: QSTileState.SideViewIcon = QSTileState.SideViewIcon.None,
+        enabledState: QSTileState.EnabledState = QSTileState.EnabledState.ENABLED,
+        expandedAccessibilityClass: KClass<out View>? = Switch::class,
+    ): QSTileState {
+        return QSTileState(
+            { Icon.Resource(iconRes, null) },
+            label,
+            activationState,
+            secondaryLabel,
+            supportedActions,
+            contentDescription,
+            stateDescription,
+            sideViewIcon,
+            enabledState,
+            expandedAccessibilityClass?.qualifiedName
+        )
+    }
+
+    @Test
+    fun mapsEnabledDataToUnavailableStateWhenOnPowerSave() {
+        val inputModel = createModel(nightMode = true, powerSave = true)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedContentDescription =
+            TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel)
+        val expectedState =
+            createUiNightModeTileState(
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                secondaryLabel = expectedSecondaryLabel,
+                contentDescription = expectedContentDescription
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun mapsDisabledDataToUnavailableStateWhenOnPowerSave() {
+        val inputModel = createModel(nightMode = false, powerSave = true)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedContentDescription =
+            TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel)
+        val expectedState =
+            createUiNightModeTileState(
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                secondaryLabel = expectedSecondaryLabel,
+                contentDescription = expectedContentDescription
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun mapsDisabledDataToInactiveState() {
+        val inputModel = createModel(nightMode = false, powerSave = false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[1]
+        val expectedState =
+            createUiNightModeTileState(
+                activationState = QSTileState.ActivationState.INACTIVE,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                contentDescription = expectedLabel
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun mapsEnabledDataToActiveState() {
+        val inputModel = createModel(true, false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[2]
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = expectedLabel
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun mapsEnabledDataToOnIconState() {
+        val inputModel = createModel(nightMode = true, powerSave = false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[2]
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = expectedLabel
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun mapsDisabledDataToOffIconState() {
+        val inputModel = createModel(nightMode = false, powerSave = false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[1]
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.INACTIVE,
+                contentDescription = expectedLabel
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun supportsClickAndLongClickActionsWhenNotInPowerSaveInNightMode() {
+        val inputModel = createModel(nightMode = true, powerSave = false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[2]
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun supportsOnlyLongClickActionWhenUnavailableInPowerSaveInNightMode() {
+        val inputModel = createModel(nightMode = true, powerSave = true)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedContentDescription =
+            TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                contentDescription = expectedContentDescription,
+                supportedActions = setOf(QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun supportsClickAndLongClickActionsWhenNotInPowerSaveNotInNightMode() {
+        val inputModel = createModel(nightMode = false, powerSave = false)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[1]
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.INACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun supportsOnlyClickActionWhenUnavailableInPowerSaveNotInNightMode() {
+        val inputModel = createModel(nightMode = false, powerSave = true)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                contentDescription = TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel),
+                supportedActions = setOf(QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenInPowerSaveMode() {
+        val inputModel = createModel(powerSave = true)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                contentDescription = TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel),
+                supportedActions = setOf(QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenInNightModeNotInPowerSaveModeLocationEnabledUiModeIsNightAuto() {
+        val inputModel =
+            createModel(
+                nightMode = true,
+                powerSave = false,
+                isLocationEnabled = true,
+                uiMode = UiModeManager.MODE_NIGHT_AUTO
+            )
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_until_sunrise)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel),
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenNotInNightModeNotInPowerSaveModeLocationEnableUiModeIsNightAuto() {
+        val inputModel =
+            createModel(
+                nightMode = false,
+                powerSave = false,
+                isLocationEnabled = true,
+                uiMode = UiModeManager.MODE_NIGHT_AUTO
+            )
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_on_at_sunset)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.INACTIVE,
+                contentDescription = TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel),
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenNotInPowerSaveAndUiModeIsNightYesInNightMode() {
+        val inputModel =
+            createModel(nightMode = true, powerSave = false, uiMode = UiModeManager.MODE_NIGHT_YES)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[2]
+
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenNotInPowerSaveAndUiModeIsNightNoNotInNightMode() {
+        val inputModel =
+            createModel(nightMode = false, powerSave = false, uiMode = UiModeManager.MODE_NIGHT_NO)
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[1]
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.INACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenNotInPowerSaveAndUiModeIsUnknownCustomNotInNightMode() {
+        val inputModel =
+            createModel(
+                nightMode = false,
+                powerSave = false,
+                uiMode = UiModeManager.MODE_NIGHT_CUSTOM,
+                nighModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN
+            )
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[1]
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.INACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenNotInPowerSaveAndUiModeIsUnknownCustomInNightMode() {
+        val inputModel =
+            createModel(
+                nightMode = true,
+                powerSave = false,
+                uiMode = UiModeManager.MODE_NIGHT_CUSTOM,
+                nighModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN
+            )
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel = context.resources.getStringArray(R.array.tile_states_dark)[2]
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_on,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.ACTIVE,
+                contentDescription = expectedLabel,
+                supportedActions =
+                    setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+
+    @Test
+    fun secondaryLabelCorrectWhenInPowerSaveAndUiModeIsUnknownCustomNotInNightMode() {
+        val inputModel =
+            createModel(
+                nightMode = false,
+                powerSave = true,
+                uiMode = UiModeManager.MODE_NIGHT_CUSTOM,
+                nighModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN
+            )
+
+        val actualState: QSTileState = mapper.map(qsTileConfig, inputModel)
+
+        val expectedSecondaryLabel =
+            context.getString(R.string.quick_settings_dark_mode_secondary_label_battery_saver)
+        val expectedLabel = context.getString(R.string.quick_settings_ui_mode_night_label)
+        val expectedContentDescription =
+            TextUtils.concat(expectedLabel, ", ", expectedSecondaryLabel)
+        val expectedState =
+            createUiNightModeTileState(
+                iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                label = expectedLabel,
+                secondaryLabel = expectedSecondaryLabel,
+                activationState = QSTileState.ActivationState.UNAVAILABLE,
+                contentDescription = expectedContentDescription,
+                supportedActions = setOf(QSTileState.UserAction.LONG_CLICK)
+            )
+        QSTileStateSubject.assertThat(actualState).isEqualTo(expectedState)
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileUserActionInteractorTest.kt
new file mode 100644
index 0000000..004ec62
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileUserActionInteractorTest.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain
+
+import android.app.UiModeManager
+import android.provider.Settings
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler
+import com.android.systemui.qs.tiles.base.actions.intentInputs
+import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx
+import com.android.systemui.qs.tiles.impl.uimodenight.UiModeNightTileModelHelper.createModel
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.interactor.UiModeNightTileUserActionInteractor
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.mock
+import com.google.common.truth.Truth
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class UiModeNightTileUserActionInteractorTest : SysuiTestCase() {
+
+    private val qsTileIntentUserActionHandler = FakeQSTileIntentUserInputHandler()
+
+    private lateinit var underTest: UiModeNightTileUserActionInteractor
+
+    @Mock private lateinit var uiModeManager: UiModeManager
+
+    @Before
+    fun setup() {
+        uiModeManager = mock<UiModeManager>()
+        underTest =
+            UiModeNightTileUserActionInteractor(
+                EmptyCoroutineContext,
+                uiModeManager,
+                qsTileIntentUserActionHandler
+            )
+    }
+
+    @Test
+    fun handleClickToEnable() = runTest {
+        val stateBeforeClick = false
+
+        underTest.handleInput(QSTileInputTestKtx.click(createModel(stateBeforeClick)))
+
+        verify(uiModeManager).setNightModeActivated(!stateBeforeClick)
+    }
+
+    @Test
+    fun handleClickToDisable() = runTest {
+        val stateBeforeClick = true
+
+        underTest.handleInput(QSTileInputTestKtx.click(createModel(stateBeforeClick)))
+
+        verify(uiModeManager).setNightModeActivated(!stateBeforeClick)
+    }
+
+    @Test
+    fun clickToEnableDoesNothingWhenInPowerSaveInNightMode() = runTest {
+        val isNightMode = true
+        val isPowerSave = true
+
+        underTest.handleInput(QSTileInputTestKtx.click(createModel(isNightMode, isPowerSave)))
+
+        verify(uiModeManager, never()).setNightModeActivated(any())
+    }
+
+    @Test
+    fun clickToEnableDoesNothingWhenInPowerSaveNotInNightMode() = runTest {
+        val isNightMode = false
+        val isPowerSave = true
+
+        underTest.handleInput(QSTileInputTestKtx.click(createModel(isNightMode, isPowerSave)))
+
+        verify(uiModeManager, never()).setNightModeActivated(any())
+    }
+
+    @Test
+    fun handleLongClickNightModeEnabled() = runTest {
+        val isNightMode = true
+
+        underTest.handleInput(QSTileInputTestKtx.longClick(createModel(isNightMode)))
+
+        Truth.assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1)
+        val intentInput = qsTileIntentUserActionHandler.intentInputs.last()
+        val actualIntentAction = intentInput.intent.action
+        val expectedIntentAction = Settings.ACTION_DARK_THEME_SETTINGS
+        Truth.assertThat(actualIntentAction).isEqualTo(expectedIntentAction)
+    }
+
+    @Test
+    fun handleLongClickNightModeDisabled() = runTest {
+        val isNightMode = false
+
+        underTest.handleInput(QSTileInputTestKtx.longClick(createModel(isNightMode)))
+
+        Truth.assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1)
+        val intentInput = qsTileIntentUserActionHandler.intentInputs.last()
+        val actualIntentAction = intentInput.intent.action
+        val expectedIntentAction = Settings.ACTION_DARK_THEME_SETTINGS
+        Truth.assertThat(actualIntentAction).isEqualTo(expectedIntentAction)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapper.kt
new file mode 100644
index 0000000..3f30c75
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/UiModeNightTileMapper.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain
+
+import android.app.UiModeManager
+import android.content.res.Resources
+import android.text.TextUtils
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
+import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
+import com.android.systemui.qs.tiles.viewmodel.QSTileState
+import com.android.systemui.res.R
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+/** Maps [UiModeNightTileModel] to [QSTileState]. */
+class UiModeNightTileMapper @Inject constructor(@Main private val resources: Resources) :
+    QSTileDataToStateMapper<UiModeNightTileModel> {
+    companion object {
+        val formatter12Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("hh:mm a")
+        val formatter24Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
+    }
+    override fun map(config: QSTileConfig, data: UiModeNightTileModel): QSTileState =
+        with(data) {
+            QSTileState.build(resources, config.uiConfig) {
+                var shouldSetSecondaryLabel = false
+
+                if (isPowerSave) {
+                    secondaryLabel =
+                        resources.getString(
+                            R.string.quick_settings_dark_mode_secondary_label_battery_saver
+                        )
+                } else if (uiMode == UiModeManager.MODE_NIGHT_AUTO && isLocationEnabled) {
+                    secondaryLabel =
+                        resources.getString(
+                            if (isNightMode)
+                                R.string.quick_settings_dark_mode_secondary_label_until_sunrise
+                            else R.string.quick_settings_dark_mode_secondary_label_on_at_sunset
+                        )
+                } else if (uiMode == UiModeManager.MODE_NIGHT_CUSTOM) {
+                    if (nightModeCustomType == UiModeManager.MODE_NIGHT_CUSTOM_TYPE_SCHEDULE) {
+                        val time: LocalTime =
+                            if (isNightMode) {
+                                customNightModeEnd
+                            } else {
+                                customNightModeStart
+                            }
+
+                        val formatter: DateTimeFormatter =
+                            if (is24HourFormat) formatter24Hour else formatter12Hour
+
+                        secondaryLabel =
+                            resources.getString(
+                                if (isNightMode)
+                                    R.string.quick_settings_dark_mode_secondary_label_until
+                                else R.string.quick_settings_dark_mode_secondary_label_on_at,
+                                formatter.format(time)
+                            )
+                    } else if (
+                        nightModeCustomType == UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME
+                    ) {
+                        secondaryLabel =
+                            resources.getString(
+                                if (isNightMode)
+                                    R.string
+                                        .quick_settings_dark_mode_secondary_label_until_bedtime_ends
+                                else R.string.quick_settings_dark_mode_secondary_label_on_at_bedtime
+                            )
+                    } else {
+                        secondaryLabel = null // undefined type of nightModeCustomType
+                        shouldSetSecondaryLabel = true
+                    }
+                } else {
+                    secondaryLabel = null
+                    shouldSetSecondaryLabel = true
+                }
+
+                contentDescription =
+                    if (TextUtils.isEmpty(secondaryLabel)) label
+                    else TextUtils.concat(label, ", ", secondaryLabel)
+                if (isPowerSave) {
+                    activationState = QSTileState.ActivationState.UNAVAILABLE
+                    if (shouldSetSecondaryLabel)
+                        secondaryLabel = resources.getStringArray(R.array.tile_states_dark)[0]
+                } else {
+                    activationState =
+                        if (isNightMode) QSTileState.ActivationState.ACTIVE
+                        else QSTileState.ActivationState.INACTIVE
+
+                    if (shouldSetSecondaryLabel) {
+                        secondaryLabel =
+                            if (activationState == QSTileState.ActivationState.INACTIVE)
+                                resources.getStringArray(R.array.tile_states_dark)[1]
+                            else resources.getStringArray(R.array.tile_states_dark)[2]
+                    }
+                }
+
+                val iconRes =
+                    if (activationState == QSTileState.ActivationState.ACTIVE)
+                        R.drawable.qs_light_dark_theme_icon_on
+                    else R.drawable.qs_light_dark_theme_icon_off
+                val iconResource = Icon.Resource(iconRes, null)
+                icon = { iconResource }
+
+                supportedActions =
+                    if (activationState == QSTileState.ActivationState.UNAVAILABLE)
+                        setOf(QSTileState.UserAction.LONG_CLICK)
+                    else setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
+            }
+        }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileDataInteractor.kt
new file mode 100644
index 0000000..c928e8a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileDataInteractor.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain.interactor
+
+import android.app.UiModeManager
+import android.content.Context
+import android.content.res.Configuration
+import android.os.UserHandle
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
+import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
+import com.android.systemui.statusbar.policy.BatteryController
+import com.android.systemui.statusbar.policy.ConfigurationController
+import com.android.systemui.statusbar.policy.LocationController
+import com.android.systemui.util.time.DateFormatUtil
+import javax.inject.Inject
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
+/** Observes ui mode night state changes providing the [UiModeNightTileModel]. */
+class UiModeNightTileDataInteractor
+@Inject
+constructor(
+    @Application private val context: Context,
+    private val configurationController: ConfigurationController,
+    private val uiModeManager: UiModeManager,
+    private val batteryController: BatteryController,
+    private val locationController: LocationController,
+    private val dateFormatUtil: DateFormatUtil,
+) : QSTileDataInteractor<UiModeNightTileModel> {
+
+    override fun tileData(
+        user: UserHandle,
+        triggers: Flow<DataUpdateTrigger>
+    ): Flow<UiModeNightTileModel> =
+        ConflatedCallbackFlow.conflatedCallbackFlow {
+            // send initial state
+            trySend(createModel())
+
+            val configurationCallback =
+                object : ConfigurationController.ConfigurationListener {
+                    override fun onUiModeChanged() {
+                        trySend(createModel())
+                    }
+                }
+            configurationController.addCallback(configurationCallback)
+
+            val batteryCallback =
+                object : BatteryController.BatteryStateChangeCallback {
+                    override fun onPowerSaveChanged(isPowerSave: Boolean) {
+                        trySend(createModel())
+                    }
+                }
+            batteryController.addCallback(batteryCallback)
+
+            val locationCallback =
+                object : LocationController.LocationChangeCallback {
+                    override fun onLocationSettingsChanged(locationEnabled: Boolean) {
+                        trySend(createModel())
+                    }
+                }
+            locationController.addCallback(locationCallback)
+
+            awaitClose {
+                configurationController.removeCallback(configurationCallback)
+                batteryController.removeCallback(batteryCallback)
+                locationController.removeCallback(locationCallback)
+            }
+        }
+
+    private fun createModel(): UiModeNightTileModel {
+        val uiMode = uiModeManager.nightMode
+        val nightMode =
+            (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
+                Configuration.UI_MODE_NIGHT_YES
+        val powerSave = batteryController.isPowerSave
+        val locationEnabled = locationController.isLocationEnabled
+        val nightModeCustomType = uiModeManager.nightModeCustomType
+        val use24HourFormat = dateFormatUtil.is24HourFormat
+        val customNightModeEnd = uiModeManager.customNightModeEnd
+        val customNightModeStart = uiModeManager.customNightModeStart
+
+        return UiModeNightTileModel(
+            uiMode,
+            nightMode,
+            powerSave,
+            locationEnabled,
+            nightModeCustomType,
+            use24HourFormat,
+            customNightModeEnd,
+            customNightModeStart
+        )
+    }
+
+    override fun availability(user: UserHandle): Flow<Boolean> = flowOf(true)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileUserActionInteractor.kt
new file mode 100644
index 0000000..00d7a62
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/interactor/UiModeNightTileUserActionInteractor.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain.interactor
+
+import android.app.UiModeManager
+import android.content.Intent
+import android.provider.Settings
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler
+import com.android.systemui.qs.tiles.base.interactor.QSTileInput
+import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
+import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.withContext
+
+/** Handles ui mode night tile clicks. */
+class UiModeNightTileUserActionInteractor
+@Inject
+constructor(
+    @Background private val backgroundContext: CoroutineContext,
+    private val uiModeManager: UiModeManager,
+    private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler,
+) : QSTileUserActionInteractor<UiModeNightTileModel> {
+
+    override suspend fun handleInput(input: QSTileInput<UiModeNightTileModel>) =
+        with(input) {
+            when (action) {
+                is QSTileUserAction.Click -> {
+                    if (!input.data.isPowerSave) {
+                        withContext(backgroundContext) {
+                            uiModeManager.setNightModeActivated(!input.data.isNightMode)
+                        }
+                    }
+                }
+                is QSTileUserAction.LongClick -> {
+                    qsTileIntentUserActionHandler.handle(
+                        action.view,
+                        Intent(Settings.ACTION_DARK_THEME_SETTINGS)
+                    )
+                }
+            }
+        }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/model/UiModeNightTileModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/model/UiModeNightTileModel.kt
new file mode 100644
index 0000000..4fa1306
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/uimodenight/domain/model/UiModeNightTileModel.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight.domain.model
+
+import java.time.LocalTime
+
+/**
+ * UiModeNight tile model. Quick Settings tile for: Night Mode / Dark Theme / Dark Mode.
+ *
+ * @param isNightMode is true when the NightMode is enabled;
+ */
+data class UiModeNightTileModel(
+    val uiMode: Int,
+    val isNightMode: Boolean,
+    val isPowerSave: Boolean,
+    val isLocationEnabled: Boolean,
+    val nightModeCustomType: Int,
+    val is24HourFormat: Boolean,
+    val customNightModeEnd: LocalTime,
+    val customNightModeStart: LocalTime
+)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/PolicyModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/PolicyModule.kt
index 0f2da2d..087e100 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/PolicyModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/PolicyModule.kt
@@ -38,6 +38,10 @@
 import com.android.systemui.qs.tiles.impl.location.domain.interactor.LocationTileDataInteractor
 import com.android.systemui.qs.tiles.impl.location.domain.interactor.LocationTileUserActionInteractor
 import com.android.systemui.qs.tiles.impl.location.domain.model.LocationTileModel
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.UiModeNightTileMapper
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.interactor.UiModeNightTileDataInteractor
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.interactor.UiModeNightTileUserActionInteractor
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
 import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
 import com.android.systemui.qs.tiles.viewmodel.QSTileUIConfig
 import com.android.systemui.qs.tiles.viewmodel.QSTileViewModel
@@ -64,6 +68,7 @@
         const val FLASHLIGHT_TILE_SPEC = "flashlight"
         const val LOCATION_TILE_SPEC = "location"
         const val ALARM_TILE_SPEC = "alarm"
+        const val UIMODENIGHT_TILE_SPEC = "dark"
 
         /** Inject flashlight config */
         @Provides
@@ -160,6 +165,38 @@
                 stateInteractor,
                 mapper,
             )
+
+        /** Inject uimodenight config */
+        @Provides
+        @IntoMap
+        @StringKey(UIMODENIGHT_TILE_SPEC)
+        fun provideUiModeNightTileConfig(uiEventLogger: QsEventLogger): QSTileConfig =
+            QSTileConfig(
+                tileSpec = TileSpec.create(UIMODENIGHT_TILE_SPEC),
+                uiConfig =
+                    QSTileUIConfig.Resource(
+                        iconRes = R.drawable.qs_light_dark_theme_icon_off,
+                        labelRes = R.string.quick_settings_ui_mode_night_label,
+                    ),
+                instanceId = uiEventLogger.getNewInstanceId(),
+            )
+
+        /** Inject uimodenight into tileViewModelMap in QSModule */
+        @Provides
+        @IntoMap
+        @StringKey(UIMODENIGHT_TILE_SPEC)
+        fun provideUiModeNightTileViewModel(
+            factory: QSTileViewModelFactory.Static<UiModeNightTileModel>,
+            mapper: UiModeNightTileMapper,
+            stateInteractor: UiModeNightTileDataInteractor,
+            userActionInteractor: UiModeNightTileUserActionInteractor
+        ): QSTileViewModel =
+            factory.create(
+                TileSpec.create(UIMODENIGHT_TILE_SPEC),
+                userActionInteractor,
+                stateInteractor,
+                mapper,
+            )
     }
 
     /** Inject FlashlightTile into tileMap in QSModule */
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileKosmos.kt
new file mode 100644
index 0000000..f0e5807
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileKosmos.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.qs.qsEventLogger
+import com.android.systemui.statusbar.policy.PolicyModule
+
+val Kosmos.qsUiModeNightTileConfig by
+    Kosmos.Fixture { PolicyModule.provideUiModeNightTileConfig(qsEventLogger) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileModelHelper.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileModelHelper.kt
new file mode 100644
index 0000000..1fe18e3
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/uimodenight/UiModeNightTileModelHelper.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2023 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.qs.tiles.impl.uimodenight
+
+import android.content.res.Configuration
+import com.android.systemui.qs.tiles.impl.uimodenight.domain.model.UiModeNightTileModel
+import java.time.LocalTime
+
+object UiModeNightTileModelHelper {
+
+    const val DEFAULT_NIGHT_MODE_CUSTOM_TYPE = 0
+    val defaultCustomNightEnd: LocalTime = LocalTime.MAX
+    val defaultCustomNightStart: LocalTime = LocalTime.MIN
+
+    fun createModel(
+        nightMode: Boolean = false,
+        powerSave: Boolean = false,
+        uiMode: Int = Configuration.UI_MODE_NIGHT_NO,
+        isLocationEnabled: Boolean = true,
+        nighModeCustomType: Int = DEFAULT_NIGHT_MODE_CUSTOM_TYPE,
+        is24HourFormat: Boolean = false,
+        customNightModeEnd: LocalTime = defaultCustomNightEnd,
+        customNightModeStart: LocalTime = defaultCustomNightStart
+    ): UiModeNightTileModel {
+        return UiModeNightTileModel(
+            uiMode,
+            nightMode,
+            powerSave,
+            isLocationEnabled,
+            nighModeCustomType,
+            is24HourFormat,
+            customNightModeEnd,
+            customNightModeStart
+        )
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeBatteryController.java b/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeBatteryController.java
index 209cac6..5ae033c 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeBatteryController.java
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeBatteryController.java
@@ -22,11 +22,16 @@
 import com.android.systemui.statusbar.policy.BatteryController.BatteryStateChangeCallback;
 
 import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
 
 public class FakeBatteryController extends BaseLeakChecker<BatteryStateChangeCallback>
         implements BatteryController {
     private boolean mIsAodPowerSave = false;
     private boolean mWirelessCharging;
+    private boolean mPowerSaveMode = false;
+
+    private final List<BatteryStateChangeCallback> mCallbacks = new ArrayList<>();
 
     public FakeBatteryController(LeakCheck test) {
         super(test, "battery");
@@ -44,12 +49,18 @@
 
     @Override
     public void setPowerSaveMode(boolean powerSave) {
-
+        mPowerSaveMode = powerSave;
+        for (BatteryStateChangeCallback callback: mCallbacks) {
+            callback.onPowerSaveChanged(powerSave);
+        }
     }
 
+    /**
+     * Note: this method ignores the View argument
+     */
     @Override
     public void setPowerSaveMode(boolean powerSave, View view) {
-
+        setPowerSaveMode(powerSave);
     }
 
     @Override
@@ -59,7 +70,7 @@
 
     @Override
     public boolean isPowerSave() {
-        return false;
+        return mPowerSaveMode;
     }
 
     @Override
@@ -79,4 +90,14 @@
     public void setWirelessCharging(boolean wirelessCharging) {
         mWirelessCharging = wirelessCharging;
     }
+
+    @Override
+    public void addCallback(BatteryStateChangeCallback listener) {
+        mCallbacks.add(listener);
+    }
+
+    @Override
+    public void removeCallback(BatteryStateChangeCallback listener) {
+        mCallbacks.remove(listener);
+    }
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeLocationController.java b/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeLocationController.java
index 3c63275..442d15b 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeLocationController.java
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/utils/leaks/FakeLocationController.java
@@ -26,6 +26,7 @@
         implements LocationController {
 
     private final List<LocationChangeCallback> mCallbacks = new ArrayList<>();
+    private boolean mLocationEnabled = false;
 
     public FakeLocationController(LeakCheck test) {
         super(test, "location");
@@ -38,13 +39,14 @@
 
     @Override
     public boolean isLocationEnabled() {
-        return false;
+        return mLocationEnabled;
     }
 
     @Override
     public boolean setLocationEnabled(boolean enabled) {
+        mLocationEnabled = enabled;
         mCallbacks.forEach(callback -> callback.onLocationSettingsChanged(enabled));
-        return false;
+        return true;
     }
 
     @Override