Dark theme toggle (2/2)

Implement using clean architecture and preview & apply pattern on the
picker side. Create an activity-scoped DarkModeLifecycleUtil to update
the singleton-scoped DarkModeRepositoryImpl during lifecycle changes.

Does not include changes to preview dark theme colors on the
picker UI or on Launcher.

Flag: com.android.systemui.shared.new_customization_picker_ui
Test: manually verified with battery saver mode on and off
Test: DarkModeViewModelTest
Bug: 371985937
Change-Id: I7b9c8a5f3e550c8c15ed6e1d5728e8f897ee2e8e
diff --git a/res/layout/floating_sheet_colors.xml b/res/layout/floating_sheet_colors.xml
index a22b264..5834caa 100644
--- a/res/layout/floating_sheet_colors.xml
+++ b/res/layout/floating_sheet_colors.xml
@@ -68,7 +68,6 @@
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:background="@null"
-                android:clickable="false"
                 android:focusable="false"
                 android:minHeight="0dp" />
         </LinearLayout>
diff --git a/src/com/android/customization/picker/mode/data/repository/DarkModeRepository.kt b/src/com/android/customization/picker/mode/data/repository/DarkModeRepository.kt
new file mode 100644
index 0000000..48d5c7f
--- /dev/null
+++ b/src/com/android/customization/picker/mode/data/repository/DarkModeRepository.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.customization.picker.mode.data.repository
+
+import com.android.app.tracing.coroutines.flow.map
+import com.android.customization.picker.mode.shared.util.DarkModeUtil
+import com.android.wallpaper.system.PowerManagerWrapper
+import com.android.wallpaper.system.UiModeManagerWrapper
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOf
+
+@Singleton
+class DarkModeRepository
+@Inject
+constructor(
+    darkModeUtil: DarkModeUtil,
+    private val uiModeManager: UiModeManagerWrapper,
+    private val powerManager: PowerManagerWrapper,
+) {
+    private val isPowerSaveMode = MutableStateFlow(powerManager.getIsPowerSaveMode() ?: false)
+
+    private val isAvailable = darkModeUtil.isAvailable()
+
+    val isEnabled =
+        if (isAvailable) {
+            isPowerSaveMode.map { !it }
+        } else flowOf(false)
+
+    private val _isDarkMode = MutableStateFlow(uiModeManager.getIsNightModeActivated())
+    val isDarkMode = _isDarkMode.asStateFlow()
+
+    fun setDarkModeActivated(isActive: Boolean) {
+        uiModeManager.setNightModeActivated(isActive)
+        refreshIsDarkModeActivated()
+    }
+
+    fun refreshIsDarkModeActivated() {
+        _isDarkMode.value = uiModeManager.getIsNightModeActivated()
+    }
+
+    fun refreshIsPowerSaveModeActivated() {
+        powerManager.getIsPowerSaveMode()?.let { isPowerSaveMode.value = it }
+    }
+}
diff --git a/src/com/android/customization/picker/mode/domain/interactor/DarkModeInteractor.kt b/src/com/android/customization/picker/mode/domain/interactor/DarkModeInteractor.kt
new file mode 100644
index 0000000..1b74e33
--- /dev/null
+++ b/src/com/android/customization/picker/mode/domain/interactor/DarkModeInteractor.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.customization.picker.mode.domain.interactor
+
+import com.android.customization.picker.mode.data.repository.DarkModeRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DarkModeInteractor @Inject constructor(private val repository: DarkModeRepository) {
+    val isEnabled = repository.isEnabled
+    val isDarkMode = repository.isDarkMode
+
+    fun setDarkModeActivated(isActive: Boolean) = repository.setDarkModeActivated(isActive)
+}
diff --git a/src/com/android/customization/picker/mode/shared/util/DarkModeLifecycleUtil.kt b/src/com/android/customization/picker/mode/shared/util/DarkModeLifecycleUtil.kt
new file mode 100644
index 0000000..749ac2e
--- /dev/null
+++ b/src/com/android/customization/picker/mode/shared/util/DarkModeLifecycleUtil.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.customization.picker.mode.shared.util
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
+import android.text.TextUtils
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import com.android.customization.picker.mode.data.repository.DarkModeRepository
+import dagger.hilt.android.qualifiers.ActivityContext
+import dagger.hilt.android.scopes.ActivityScoped
+import javax.inject.Inject
+
+/**
+ * This class observes the activity lifecycle and updates the DarkModeRepositoryImpl based on
+ * lifecycle phases.
+ */
+@ActivityScoped
+class DarkModeLifecycleUtil
+@Inject
+constructor(
+    @ActivityContext private val activityContext: Context,
+    private val darkModeRepository: DarkModeRepository,
+) {
+    private val lifecycleOwner = activityContext as LifecycleOwner
+
+    private val batterySaverStateReceiver =
+        object : BroadcastReceiver() {
+            override fun onReceive(context: Context?, intent: Intent?) {
+                if (
+                    intent != null &&
+                        TextUtils.equals(intent.action, PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
+                ) {
+                    darkModeRepository.refreshIsPowerSaveModeActivated()
+                }
+            }
+        }
+    private val lifecycleObserver =
+        object : DefaultLifecycleObserver {
+            @Synchronized
+            override fun onStart(owner: LifecycleOwner) {
+                super.onStart(owner)
+                darkModeRepository.refreshIsDarkModeActivated()
+                darkModeRepository.refreshIsPowerSaveModeActivated()
+                if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+                    activityContext.registerReceiver(
+                        batterySaverStateReceiver,
+                        IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED),
+                    )
+                }
+            }
+
+            @Synchronized
+            override fun onStop(owner: LifecycleOwner) {
+                super.onStop(owner)
+                activityContext.unregisterReceiver(batterySaverStateReceiver)
+            }
+
+            @Synchronized
+            override fun onDestroy(owner: LifecycleOwner) {
+                super.onDestroy(owner)
+                lifecycleOwner.lifecycle.removeObserver(this)
+            }
+        }
+
+    init {
+        lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
+    }
+}
diff --git a/src/com/android/customization/picker/mode/shared/util/DarkModeUtil.kt b/src/com/android/customization/picker/mode/shared/util/DarkModeUtil.kt
new file mode 100644
index 0000000..9ad514d
--- /dev/null
+++ b/src/com/android/customization/picker/mode/shared/util/DarkModeUtil.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.customization.picker.mode.shared.util
+
+interface DarkModeUtil {
+    fun isAvailable(): Boolean
+}
diff --git a/src/com/android/customization/picker/mode/shared/util/DarkModeUtilImpl.kt b/src/com/android/customization/picker/mode/shared/util/DarkModeUtilImpl.kt
new file mode 100644
index 0000000..a8e8535
--- /dev/null
+++ b/src/com/android/customization/picker/mode/shared/util/DarkModeUtilImpl.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.customization.picker.mode.shared.util
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DarkModeUtilImpl @Inject constructor(@ApplicationContext private val context: Context) :
+    DarkModeUtil {
+    override fun isAvailable(): Boolean {
+        return (ContextCompat.checkSelfPermission(
+            context,
+            Manifest.permission.MODIFY_DAY_NIGHT_MODE,
+        ) == PackageManager.PERMISSION_GRANTED)
+    }
+}
diff --git a/src/com/android/customization/picker/mode/shared/util/FakeDarkModeUtil.kt b/src/com/android/customization/picker/mode/shared/util/FakeDarkModeUtil.kt
new file mode 100644
index 0000000..f0225ef
--- /dev/null
+++ b/src/com/android/customization/picker/mode/shared/util/FakeDarkModeUtil.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.customization.picker.mode.shared.util
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FakeDarkModeUtil @Inject constructor() : DarkModeUtil {
+    override fun isAvailable(): Boolean {
+        return true
+    }
+}
diff --git a/src/com/android/customization/picker/mode/ui/binder/DarkModeBinder.kt b/src/com/android/customization/picker/mode/ui/binder/DarkModeBinder.kt
new file mode 100644
index 0000000..b9c7041
--- /dev/null
+++ b/src/com/android/customization/picker/mode/ui/binder/DarkModeBinder.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.customization.picker.mode.ui.binder
+
+import android.widget.Switch
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.customization.picker.mode.ui.viewmodel.DarkModeViewModel
+import kotlinx.coroutines.launch
+
+object DarkModeBinder {
+    fun bind(darkModeToggle: Switch, viewModel: DarkModeViewModel, lifecycleOwner: LifecycleOwner) {
+        lifecycleOwner.lifecycleScope.launch {
+            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch { viewModel.isEnabled.collect { darkModeToggle.isEnabled = it } }
+                launch { viewModel.previewingIsDarkMode.collect { darkModeToggle.isChecked = it } }
+                launch {
+                    viewModel.toggleDarkMode.collect {
+                        darkModeToggle.setOnCheckedChangeListener { _, _ -> it.invoke() }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModel.kt b/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModel.kt
new file mode 100644
index 0000000..2bcb644
--- /dev/null
+++ b/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModel.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.customization.picker.mode.ui.viewmodel
+
+import com.android.customization.module.logging.ThemesUserEventLogger
+import com.android.customization.picker.mode.domain.interactor.DarkModeInteractor
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+
+@ViewModelScoped
+class DarkModeViewModel
+@Inject
+constructor(private val interactor: DarkModeInteractor, private val logger: ThemesUserEventLogger) {
+    private val isDarkMode = interactor.isDarkMode
+    val isEnabled = interactor.isEnabled
+
+    private val overridingIsDarkMode = MutableStateFlow<Boolean?>(null)
+    val previewingIsDarkMode =
+        combine(overridingIsDarkMode, isDarkMode, isEnabled) { override, current, isEnabled ->
+            if (isEnabled) {
+                override ?: current
+            } else current
+        }
+
+    val toggleDarkMode =
+        combine(overridingIsDarkMode, isDarkMode) { override, current ->
+            { overridingIsDarkMode.value = override?.not() ?: !current }
+        }
+
+    val onApply: Flow<(suspend () -> Unit)?> =
+        combine(overridingIsDarkMode, isDarkMode, isEnabled) { override, current, isEnabled ->
+            if (override != null && override != current && isEnabled) {
+                {
+                    interactor.setDarkModeActivated(override)
+                    logger.logDarkThemeApplied(override)
+                }
+            } else null
+        }
+
+    fun resetPreview() {
+        overridingIsDarkMode.value = null
+    }
+}
diff --git a/src/com/android/wallpaper/customization/ui/binder/ColorsFloatingSheetBinder.kt b/src/com/android/wallpaper/customization/ui/binder/ColorsFloatingSheetBinder.kt
index b06748a..e2a8cf3 100644
--- a/src/com/android/wallpaper/customization/ui/binder/ColorsFloatingSheetBinder.kt
+++ b/src/com/android/wallpaper/customization/ui/binder/ColorsFloatingSheetBinder.kt
@@ -32,6 +32,7 @@
 import com.android.customization.picker.color.ui.view.ColorOptionIconView
 import com.android.customization.picker.color.ui.viewmodel.ColorOptionIconViewModel
 import com.android.customization.picker.common.ui.view.DoubleRowListItemSpacing
+import com.android.customization.picker.mode.ui.binder.DarkModeBinder
 import com.android.themepicker.R
 import com.android.wallpaper.customization.ui.util.ThemePickerCustomizationOptionUtil.ThemePickerHomeCustomizationOption.COLORS
 import com.android.wallpaper.customization.ui.viewmodel.ThemePickerCustomizationOptionsViewModel
@@ -65,10 +66,16 @@
         val tabAdapter =
             FloatingToolbarTabAdapter(
                     colorUpdateViewModel = WeakReference(colorUpdateViewModel),
-                    shouldAnimateColor = { optionsViewModel.selectedOption.value == COLORS }
+                    shouldAnimateColor = { optionsViewModel.selectedOption.value == COLORS },
                 )
                 .also { tabs.setAdapter(it) }
 
+        DarkModeBinder.bind(
+            darkModeToggle = view.findViewById(R.id.dark_mode_toggle),
+            viewModel = optionsViewModel.darkModeViewModel,
+            lifecycleOwner = lifecycleOwner,
+        )
+
         lifecycleOwner.lifecycleScope.launch {
             lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                 launch { viewModel.colorTypeTabs.collect { tabAdapter.submitList(it) } }
@@ -91,7 +98,7 @@
 
     private fun createOptionItemAdapter(
         uiMode: Int,
-        lifecycleOwner: LifecycleOwner
+        lifecycleOwner: LifecycleOwner,
     ): OptionItemAdapter<ColorOptionIconViewModel> =
         OptionItemAdapter(
             layoutResourceId = R.layout.color_option,
@@ -100,7 +107,7 @@
                 val colorOptionIconView = foregroundView as? ColorOptionIconView
                 val night = uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
                 colorOptionIconView?.let { ColorOptionIconBinder.bind(it, colorIcon, night) }
-            }
+            },
         )
 
     private fun RecyclerView.initColorsList(
@@ -109,13 +116,7 @@
     ) {
         apply {
             this.adapter = adapter
-            layoutManager =
-                GridLayoutManager(
-                    context,
-                    2,
-                    GridLayoutManager.HORIZONTAL,
-                    false,
-                )
+            layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
             addItemDecoration(
                 DoubleRowListItemSpacing(
                     context.resources.getDimensionPixelSize(
diff --git a/src/com/android/wallpaper/customization/ui/util/ThemePickerCustomizationOptionUtil.kt b/src/com/android/wallpaper/customization/ui/util/ThemePickerCustomizationOptionUtil.kt
index 7a73b7d..6789e2b 100644
--- a/src/com/android/wallpaper/customization/ui/util/ThemePickerCustomizationOptionUtil.kt
+++ b/src/com/android/wallpaper/customization/ui/util/ThemePickerCustomizationOptionUtil.kt
@@ -21,6 +21,7 @@
 import android.view.ViewGroup
 import android.widget.FrameLayout
 import android.widget.LinearLayout
+import com.android.customization.picker.mode.shared.util.DarkModeLifecycleUtil
 import com.android.themepicker.R
 import com.android.wallpaper.model.Screen
 import com.android.wallpaper.model.Screen.HOME_SCREEN
@@ -36,6 +37,9 @@
 constructor(private val defaultCustomizationOptionUtil: DefaultCustomizationOptionUtil) :
     CustomizationOptionUtil {
 
+    // Instantiate DarkModeLifecycleUtil for it to observe lifecycle and update DarkModeRepository
+    @Inject lateinit var darkModeLifecycleUtil: DarkModeLifecycleUtil
+
     enum class ThemePickerLockCustomizationOption : CustomizationOptionUtil.CustomizationOption {
         CLOCK,
         SHORTCUTS,
diff --git a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
index d114ac8..4707f81 100644
--- a/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
+++ b/src/com/android/wallpaper/customization/ui/viewmodel/ThemePickerCustomizationOptionsViewModel.kt
@@ -16,6 +16,7 @@
 
 package com.android.wallpaper.customization.ui.viewmodel
 
+import com.android.customization.picker.mode.ui.viewmodel.DarkModeViewModel
 import com.android.wallpaper.customization.ui.util.ThemePickerCustomizationOptionUtil
 import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModel
 import com.android.wallpaper.picker.customization.ui.viewmodel.CustomizationOptionsViewModelFactory
@@ -28,6 +29,7 @@
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.map
@@ -42,6 +44,7 @@
     colorPickerViewModel2Factory: ColorPickerViewModel2.Factory,
     clockPickerViewModelFactory: ClockPickerViewModel.Factory,
     shapeAndGridPickerViewModelFactory: ShapeAndGridPickerViewModel.Factory,
+    val darkModeViewModel: DarkModeViewModel,
     @Assisted private val viewModelScope: CoroutineScope,
 ) : CustomizationOptionsViewModel {
 
@@ -62,6 +65,7 @@
         shapeAndGridPickerViewModel.resetPreview()
         clockPickerViewModel.resetPreview()
         colorPickerViewModel2.resetPreview()
+        darkModeViewModel.resetPreview()
         return defaultCustomizationOptionsViewModel.deselectOption()
     }
 
@@ -131,7 +135,14 @@
                     ThemePickerCustomizationOptionUtil.ThemePickerHomeCustomizationOption
                         .APP_SHAPE_AND_GRID -> shapeAndGridPickerViewModel.onApply
                     ThemePickerCustomizationOptionUtil.ThemePickerHomeCustomizationOption.COLORS ->
-                        colorPickerViewModel2.onApply
+                        combine(colorPickerViewModel2.onApply, darkModeViewModel.onApply) {
+                            colorOnApply,
+                            darkModeOnApply ->
+                            {
+                                colorOnApply?.invoke()
+                                darkModeOnApply?.invoke()
+                            }
+                        }
                     else -> flow { emit(null) }
                 }
             }
diff --git a/src/com/android/wallpaper/picker/di/modules/ThemePickerSharedAppModule.kt b/src/com/android/wallpaper/picker/di/modules/ThemePickerSharedAppModule.kt
index 0b32196..22e5fe5 100644
--- a/src/com/android/wallpaper/picker/di/modules/ThemePickerSharedAppModule.kt
+++ b/src/com/android/wallpaper/picker/di/modules/ThemePickerSharedAppModule.kt
@@ -18,6 +18,8 @@
 
 import com.android.customization.model.grid.DefaultGridOptionsManager
 import com.android.customization.model.grid.GridOptionsManager2
+import com.android.customization.picker.mode.shared.util.DarkModeUtil
+import com.android.customization.picker.mode.shared.util.DarkModeUtilImpl
 import dagger.Binds
 import dagger.Module
 import dagger.hilt.InstallIn
@@ -31,4 +33,6 @@
     @Binds
     @Singleton
     abstract fun bindGridOptionsManager2(impl: DefaultGridOptionsManager): GridOptionsManager2
+
+    @Binds @Singleton abstract fun bindDarkModeUtil(impl: DarkModeUtilImpl): DarkModeUtil
 }
diff --git a/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt b/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
index 4651067..05c95b0 100644
--- a/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
+++ b/tests/common/src/com/android/customization/module/logging/TestThemesUserEventLogger.kt
@@ -40,6 +40,9 @@
 
     var shortcutLogs: List<Pair<String, String>> = emptyList()
 
+    var useDarkTheme: Boolean = false
+        private set
+
     override fun logThemeColorApplied(@ColorSource source: Int, style: Int, seedColor: Int) {
         this.themeColorSource = source
         this.themeColorStyle = style
@@ -64,7 +67,9 @@
         shortcutLogs = shortcutLogs.toMutableList().apply { add(shortcut to shortcutSlotId) }
     }
 
-    override fun logDarkThemeApplied(useDarkTheme: Boolean) {}
+    override fun logDarkThemeApplied(useDarkTheme: Boolean) {
+        this.useDarkTheme = useDarkTheme
+    }
 
     @ClockSize
     fun getLoggedClockSize(): Int {
diff --git a/tests/common/src/com/android/wallpaper/di/modules/ThemePickerSharedAppTestModule.kt b/tests/common/src/com/android/wallpaper/di/modules/ThemePickerSharedAppTestModule.kt
index 7781d4e..3112f75 100644
--- a/tests/common/src/com/android/wallpaper/di/modules/ThemePickerSharedAppTestModule.kt
+++ b/tests/common/src/com/android/wallpaper/di/modules/ThemePickerSharedAppTestModule.kt
@@ -18,6 +18,8 @@
 
 import com.android.customization.model.grid.FakeGridOptionsManager
 import com.android.customization.model.grid.GridOptionsManager2
+import com.android.customization.picker.mode.shared.util.DarkModeUtil
+import com.android.customization.picker.mode.shared.util.FakeDarkModeUtil
 import com.android.wallpaper.picker.di.modules.ThemePickerSharedAppModule
 import dagger.Binds
 import dagger.Module
@@ -28,11 +30,13 @@
 @Module
 @TestInstallIn(
     components = [SingletonComponent::class],
-    replaces = [ThemePickerSharedAppModule::class]
+    replaces = [ThemePickerSharedAppModule::class],
 )
 abstract class ThemePickerSharedAppTestModule {
 
     @Binds
     @Singleton
     abstract fun bindGridOptionsManager2(impl: FakeGridOptionsManager): GridOptionsManager2
+
+    @Binds @Singleton abstract fun bindDarkModeUtil(impl: FakeDarkModeUtil): DarkModeUtil
 }
diff --git a/tests/robotests/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModelTest.kt b/tests/robotests/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModelTest.kt
new file mode 100644
index 0000000..4078803
--- /dev/null
+++ b/tests/robotests/src/com/android/customization/picker/mode/ui/viewmodel/DarkModeViewModelTest.kt
@@ -0,0 +1,137 @@
+/*
+ * 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.customization.picker.mode.ui.viewmodel
+
+import com.android.customization.module.logging.TestThemesUserEventLogger
+import com.android.customization.picker.mode.data.repository.DarkModeRepository
+import com.android.customization.picker.mode.domain.interactor.DarkModeInteractor
+import com.android.wallpaper.testing.FakePowerManager
+import com.android.wallpaper.testing.FakeUiModeManager
+import com.android.wallpaper.testing.collectLastValue
+import com.google.common.truth.Truth.assertThat
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@HiltAndroidTest
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class DarkModeViewModelTest {
+    @get:Rule var hiltRule = HiltAndroidRule(this)
+
+    @Inject lateinit var uiModeManager: FakeUiModeManager
+    @Inject lateinit var powerManager: FakePowerManager
+    @Inject lateinit var darkModeRepository: DarkModeRepository
+    @Inject lateinit var darkModeInteractor: DarkModeInteractor
+    @Inject lateinit var logger: TestThemesUserEventLogger
+    lateinit var darkModeViewModel: DarkModeViewModel
+
+    @Inject lateinit var testDispatcher: TestDispatcher
+    @Inject lateinit var testScope: TestScope
+
+    @Before
+    fun setUp() {
+        hiltRule.inject()
+        Dispatchers.setMain(testDispatcher)
+
+        darkModeViewModel = DarkModeViewModel(darkModeInteractor, logger)
+    }
+
+    @Test
+    fun isEnabled_powerSaveModeOn() {
+        testScope.runTest {
+            powerManager.setIsPowerSaveMode(true)
+            darkModeRepository.refreshIsPowerSaveModeActivated()
+
+            val isEnabled = collectLastValue(darkModeViewModel.isEnabled)()
+
+            assertThat(isEnabled).isFalse()
+        }
+    }
+
+    @Test
+    fun isEnabled_powerSaveModeOff() {
+        testScope.runTest {
+            powerManager.setIsPowerSaveMode(false)
+            darkModeRepository.refreshIsPowerSaveModeActivated()
+
+            val isEnabled = collectLastValue(darkModeViewModel.isEnabled)()
+
+            assertThat(isEnabled).isTrue()
+        }
+    }
+
+    @Test
+    fun toggleDarkMode() {
+        testScope.runTest {
+            uiModeManager.setNightModeActivated(false)
+            darkModeRepository.refreshIsDarkModeActivated()
+            val getPreviewingIsDarkMode = collectLastValue(darkModeViewModel.previewingIsDarkMode)
+            val getToggleDarkMode = collectLastValue(darkModeViewModel.toggleDarkMode)
+            assertThat(getPreviewingIsDarkMode()).isFalse()
+
+            getToggleDarkMode()?.invoke()
+
+            assertThat(getPreviewingIsDarkMode()).isTrue()
+
+            getToggleDarkMode()?.invoke()
+
+            assertThat(getPreviewingIsDarkMode()).isFalse()
+        }
+    }
+
+    @Test
+    fun onApply_shouldLogDarkTheme() {
+        testScope.runTest {
+            uiModeManager.setNightModeActivated(false)
+            darkModeRepository.refreshIsDarkModeActivated()
+            val getToggleDarkMode = collectLastValue(darkModeViewModel.toggleDarkMode)
+            val onApply = collectLastValue(darkModeViewModel.onApply)
+
+            getToggleDarkMode()?.invoke()
+            onApply()?.invoke()
+
+            assertThat(logger.useDarkTheme).isTrue()
+        }
+    }
+
+    @Test
+    fun onApply_shouldApplyDarkTheme() {
+        testScope.runTest {
+            uiModeManager.setNightModeActivated(false)
+            darkModeRepository.refreshIsDarkModeActivated()
+            val getToggleDarkMode = collectLastValue(darkModeViewModel.toggleDarkMode)
+            val onApply = collectLastValue(darkModeViewModel.onApply)
+
+            getToggleDarkMode()?.invoke()
+            onApply()?.invoke()
+
+            assertThat(uiModeManager.getIsNightModeActivated()).isTrue()
+        }
+    }
+}