diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
index 2989b5370..f9b63efe4 100644
--- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
@@ -6,8 +6,10 @@ import androidx.activity.compose.setContent
 import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.runtime.getValue
 import androidx.core.os.LocaleListCompat
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
 import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -37,7 +39,10 @@ class MainActivity : AppCompatActivity() {
             AppCompatDelegate.setApplicationLocales(localeList)
         }
         setContent {
-            BitwardenTheme {
+            val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
+            BitwardenTheme(
+                theme = state.theme,
+            ) {
                 RootNavScreen(
                     onSplashScreenRemoved = { shouldShowSplashScreen = false },
                 )
@@ -47,7 +52,7 @@ class MainActivity : AppCompatActivity() {
 
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
-        mainViewModel.sendAction(
+        mainViewModel.trySendAction(
             action = MainAction.ReceiveNewIntent(
                 intent = intent,
             ),
diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
index 26b0154c9..56f70cd87 100644
--- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
@@ -1,10 +1,18 @@
 package com.x8bit.bitwarden
 
 import android.content.Intent
-import androidx.lifecycle.ViewModel
+import android.os.Parcelable
+import androidx.lifecycle.viewModelScope
 import com.x8bit.bitwarden.data.auth.repository.AuthRepository
 import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
+import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
+import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.parcelize.Parcelize
 import javax.inject.Inject
 
 /**
@@ -13,18 +21,32 @@ import javax.inject.Inject
 @HiltViewModel
 class MainViewModel @Inject constructor(
     private val authRepository: AuthRepository,
-) : ViewModel() {
-    /**
-     * Send a [MainAction].
-     */
-    fun sendAction(action: MainAction) {
+    settingsRepository: SettingsRepository,
+) : BaseViewModel<MainState, Unit, MainAction>(
+    MainState(
+        theme = settingsRepository.appTheme,
+    ),
+) {
+    init {
+        settingsRepository
+            .appThemeStateFlow
+            .onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) }
+            .launchIn(viewModelScope)
+    }
+
+    override fun handleAction(action: MainAction) {
         when (action) {
-            is MainAction.ReceiveNewIntent -> handleNewIntentReceived(intent = action.intent)
+            is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
+            is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
         }
     }
 
-    private fun handleNewIntentReceived(intent: Intent) {
-        val captchaCallbackTokenResult = intent.getCaptchaCallbackTokenResult()
+    private fun handleAppThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
+        mutableStateFlow.update { it.copy(theme = action.theme) }
+    }
+
+    private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) {
+        val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
         when {
             captchaCallbackTokenResult != null -> {
                 authRepository.setCaptchaCallbackTokenResult(
@@ -37,6 +59,14 @@ class MainViewModel @Inject constructor(
     }
 }
 
+/**
+ * Models state for the [MainActivity].
+ */
+@Parcelize
+data class MainState(
+    val theme: AppTheme,
+) : Parcelable
+
 /**
  * Models actions for the [MainActivity].
  */
@@ -45,4 +75,16 @@ sealed class MainAction {
      * Receive Intent by the application.
      */
     data class ReceiveNewIntent(val intent: Intent) : MainAction()
+
+    /**
+     * Actions for internal use by the ViewModel.
+     */
+    sealed class Internal : MainAction() {
+        /**
+         * Indicates that the app theme has changed.
+         */
+        data class ThemeUpdate(
+            val theme: AppTheme,
+        ) : Internal()
+    }
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt
index a7b1af1cd..4d5dab6a8 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt
@@ -2,11 +2,13 @@ package com.x8bit.bitwarden.data.platform.datasource.disk
 
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.flow.Flow
 
 /**
  * Primary access point for general settings-related disk information.
  */
+@Suppress("TooManyFunctions")
 interface SettingsDiskSource {
 
     /**
@@ -14,6 +16,16 @@ interface SettingsDiskSource {
      */
     var appLanguage: AppLanguage?
 
+    /**
+     * The currently persisted app theme (or `null` if not set).
+     */
+    var appTheme: AppTheme
+
+    /**
+     * Emits updates that track [appTheme].
+     */
+    val appThemeFlow: Flow<AppTheme>
+
     /**
      * The currently persisted setting for getting login item icons (or `null` if not set).
      */
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt
index e86c4c636..a158ba039 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt
@@ -5,11 +5,13 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companio
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.onSubscription
 
 private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale"
+private const val APP_THEME_KEY = "$BASE_KEY:theme"
 private const val PULL_TO_REFRESH_KEY = "$BASE_KEY:syncOnRefresh"
 private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction"
 private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout"
@@ -23,6 +25,9 @@ class SettingsDiskSourceImpl(
     val sharedPreferences: SharedPreferences,
 ) : BaseDiskSource(sharedPreferences = sharedPreferences),
     SettingsDiskSource {
+    private val mutableAppThemeFlow =
+        bufferedMutableSharedFlow<AppTheme>(replay = 1)
+
     private val mutableVaultTimeoutActionFlowMap =
         mutableMapOf<String, MutableSharedFlow<VaultTimeoutAction?>>()
 
@@ -47,6 +52,24 @@ class SettingsDiskSourceImpl(
             )
         }
 
+    override var appTheme: AppTheme
+        get() = getString(key = APP_THEME_KEY)
+            ?.let { storedValue ->
+                AppTheme.entries.firstOrNull { storedValue == it.value }
+            }
+            ?: AppTheme.DEFAULT
+        set(newValue) {
+            putString(
+                key = APP_THEME_KEY,
+                value = newValue.value,
+            )
+            mutableAppThemeFlow.tryEmit(appTheme)
+        }
+
+    override val appThemeFlow: Flow<AppTheme>
+        get() = mutableAppThemeFlow
+            .onSubscription { emit(appTheme) }
+
     override var isIconLoadingDisabled: Boolean?
         get() = getBoolean(key = DISABLE_ICON_LOADING_KEY)
         set(value) {
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
index 7f99408ce..9f532d6a4 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.StateFlow
 
@@ -16,6 +17,16 @@ interface SettingsRepository {
      */
     var appLanguage: AppLanguage
 
+    /**
+     * The currently stored [AppTheme].
+     */
+    var appTheme: AppTheme
+
+    /**
+     * Tracks changes to the [AppTheme].
+     */
+    val appThemeStateFlow: StateFlow<AppTheme>
+
     /**
      * The current setting for getting login item icons.
      */
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
index 3ad8136c8..d9fffebcf 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
@@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -35,6 +36,17 @@ class SettingsRepositoryImpl(
             settingsDiskSource.appLanguage = value
         }
 
+    override var appTheme: AppTheme by settingsDiskSource::appTheme
+
+    override val appThemeStateFlow: StateFlow<AppTheme>
+        get() = settingsDiskSource
+            .appThemeFlow
+            .stateIn(
+                scope = unconfinedScope,
+                started = SharingStarted.Eagerly,
+                initialValue = settingsDiskSource.appTheme,
+            )
+
     override var isIconLoadingDisabled: Boolean
         get() = settingsDiskSource.isIconLoadingDisabled ?: false
         set(value) {
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt
index c512359cc..ab03a9790 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt
@@ -10,6 +10,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.tooling.preview.Preview
 import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
 
 /**
@@ -42,7 +43,7 @@ fun BitwardenPlaceholderAccountActionItem(
 @Preview
 @Composable
 private fun BitwardenPlaceholderAccountActionItem_preview_light() {
-    BitwardenTheme(darkTheme = false) {
+    BitwardenTheme(theme = AppTheme.LIGHT) {
         BitwardenPlaceholderAccountActionItem(
             onClick = {},
         )
@@ -52,7 +53,7 @@ private fun BitwardenPlaceholderAccountActionItem_preview_light() {
 @Preview
 @Composable
 private fun BitwardenPlaceholderAccountActionItem_preview_dark() {
-    BitwardenTheme(darkTheme = true) {
+    BitwardenTheme(theme = AppTheme.DARK) {
         BitwardenPlaceholderAccountActionItem(
             onClick = {},
         )
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt
index 5e6b0ef89..8723c711d 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt
@@ -36,6 +36,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
 import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
 import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
+import com.x8bit.bitwarden.ui.platform.util.displayLabel
 
 /**
  * Displays the appearance screen.
@@ -161,8 +163,8 @@ private fun LanguageSelectionRow(
 
 @Composable
 private fun ThemeSelectionRow(
-    currentSelection: AppearanceState.Theme,
-    onThemeSelection: (AppearanceState.Theme) -> Unit,
+    currentSelection: AppTheme,
+    onThemeSelection: (AppTheme) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     var shouldShowThemeSelectionDialog by remember { mutableStateOf(false) }
@@ -174,7 +176,7 @@ private fun ThemeSelectionRow(
         modifier = modifier,
     ) {
         Text(
-            text = currentSelection.text(),
+            text = currentSelection.displayLabel(),
             style = MaterialTheme.typography.labelSmall,
             color = MaterialTheme.colorScheme.onSurfaceVariant,
         )
@@ -185,14 +187,14 @@ private fun ThemeSelectionRow(
             title = stringResource(id = R.string.theme),
             onDismissRequest = { shouldShowThemeSelectionDialog = false },
         ) {
-            AppearanceState.Theme.entries.forEach { option ->
+            AppTheme.entries.forEach { option ->
                 BitwardenSelectionRow(
-                    text = option.text,
+                    text = option.displayLabel,
                     isSelected = option == currentSelection,
                     onClick = {
                         shouldShowThemeSelectionDialog = false
                         onThemeSelection(
-                            AppearanceState.Theme.entries.first { it == option },
+                            AppTheme.entries.first { it == option },
                         )
                     },
                 )
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt
index bb6709979..cb61078c1 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt
@@ -4,12 +4,10 @@ import android.os.Parcelable
 import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.os.LocaleListCompat
 import androidx.lifecycle.SavedStateHandle
-import com.x8bit.bitwarden.R
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
-import com.x8bit.bitwarden.ui.platform.base.util.Text
-import com.x8bit.bitwarden.ui.platform.base.util.asText
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.flow.update
 import kotlinx.parcelize.Parcelize
@@ -29,7 +27,7 @@ class AppearanceViewModel @Inject constructor(
         ?: AppearanceState(
             language = settingsRepository.appLanguage,
             showWebsiteIcons = !settingsRepository.isIconLoadingDisabled,
-            theme = AppearanceState.Theme.DEFAULT,
+            theme = settingsRepository.appTheme,
         ),
 ) {
     override fun handleAction(action: AppearanceAction): Unit = when (action) {
@@ -63,8 +61,8 @@ class AppearanceViewModel @Inject constructor(
     }
 
     private fun handleThemeChanged(action: AppearanceAction.ThemeChange) {
-        // TODO: BIT-1327 add theme support
         mutableStateFlow.update { it.copy(theme = action.theme) }
+        settingsRepository.appTheme = action.theme
     }
 }
 
@@ -75,17 +73,8 @@ class AppearanceViewModel @Inject constructor(
 data class AppearanceState(
     val language: AppLanguage,
     val showWebsiteIcons: Boolean,
-    val theme: Theme,
-) : Parcelable {
-    /**
-     * Represents the theme options the user can set.
-     */
-    enum class Theme(val text: Text) {
-        DEFAULT(text = R.string.default_system.asText()),
-        DARK(text = R.string.dark.asText()),
-        LIGHT(text = R.string.light.asText()),
-    }
-}
+    val theme: AppTheme,
+) : Parcelable
 
 /**
  * Models events for the appearance screen.
@@ -124,6 +113,6 @@ sealed class AppearanceAction {
      * Indicates that the user selected a new theme.
      */
     data class ThemeChange(
-        val theme: AppearanceState.Theme,
+        val theme: AppTheme,
     ) : AppearanceAction()
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt
new file mode 100644
index 000000000..b9e88ebc4
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt
@@ -0,0 +1,12 @@
+package com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model
+
+/**
+ * Represents the theme options the user can set.
+ *
+ * The [value] is used for consistent storage purposes.
+ */
+enum class AppTheme(val value: String?) {
+    DEFAULT(value = null),
+    DARK(value = "dark"),
+    LIGHT(value = "light"),
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt
index bb65c0142..6a3a2a8c3 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt
@@ -24,19 +24,25 @@ import androidx.core.view.WindowCompat
 import com.x8bit.bitwarden.R
 import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
 import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
 import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImpl
 
 /**
- * The overall application theme. This can be configured to support a [darkTheme] and
- * [dynamicColor].
+ * The overall application theme. This can be configured to support a [theme] and [dynamicColor].
  */
 @Composable
 fun BitwardenTheme(
-    darkTheme: Boolean = isSystemInDarkTheme(),
+    theme: AppTheme = AppTheme.DEFAULT,
     dynamicColor: Boolean = false,
     content: @Composable () -> Unit,
 ) {
+    val darkTheme = when (theme) {
+        AppTheme.DEFAULT -> isSystemInDarkTheme()
+        AppTheme.DARK -> true
+        AppTheme.LIGHT -> false
+    }
+
     // Get the current scheme
     val context = LocalContext.current
     val colorScheme = when {
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt
new file mode 100644
index 000000000..3dde99f1f
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt
@@ -0,0 +1,16 @@
+package com.x8bit.bitwarden.ui.platform.util
+
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.ui.platform.base.util.Text
+import com.x8bit.bitwarden.ui.platform.base.util.asText
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
+
+/**
+ * Returns a human-readable display label for the given [AppTheme].
+ */
+val AppTheme.displayLabel: Text
+    get() = when (this) {
+        AppTheme.DEFAULT -> R.string.default_system.asText()
+        AppTheme.DARK -> R.string.dark.asText()
+        AppTheme.LIGHT -> R.string.light.asText()
+    }
diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
index da6c70f91..2e02117bd 100644
--- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
@@ -2,48 +2,78 @@ package com.x8bit.bitwarden
 
 import android.content.Intent
 import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.auth.repository.model.UserState
 import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
+import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
+import com.x8bit.bitwarden.data.platform.repository.model.Environment
+import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import io.mockk.every
+import io.mockk.just
 import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkStatic
+import io.mockk.runs
 import io.mockk.verify
-import org.junit.Test
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
 
-class MainViewModelTest {
+class MainViewModelTest : BaseViewModelTest() {
 
-    @BeforeEach
-    fun setUp() {
-        mockkStatic(LOGIN_RESULT_PATH)
+    private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
+    private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
+    val authRepository = mockk<AuthRepository> {
+        every { userStateFlow } returns mutableUserStateFlow
+        every { activeUserId } returns USER_ID
+        every {
+            setCaptchaCallbackTokenResult(
+                tokenResult = CaptchaCallbackTokenResult.Success(
+                    token = "mockk_token",
+                ),
+            )
+        } just runs
     }
-
-    @AfterEach
-    fun tearDown() {
-        unmockkStatic(LOGIN_RESULT_PATH)
+    private val settingsRepository = mockk<SettingsRepository> {
+        every { appTheme } returns AppTheme.DEFAULT
+        every { appThemeStateFlow } returns mutableAppThemeFlow
     }
 
     @Test
-    fun `ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
-        val authRepository = mockk<AuthRepository> {
-            every {
-                setCaptchaCallbackTokenResult(
-                    tokenResult = CaptchaCallbackTokenResult.Success(
-                        token = "mockk_token",
-                    ),
-                )
-            } returns Unit
+    fun `on AppThemeChanged should update state`() {
+        val viewModel = createViewModel()
+
+        assertEquals(
+            MainState(
+                theme = AppTheme.DEFAULT,
+            ),
+            viewModel.stateFlow.value,
+        )
+        viewModel.trySendAction(
+            MainAction.Internal.ThemeUpdate(
+                theme = AppTheme.DARK,
+            ),
+        )
+        assertEquals(
+            MainState(
+                theme = AppTheme.DARK,
+            ),
+            viewModel.stateFlow.value,
+        )
+
+        verify {
+            settingsRepository.appTheme
+            settingsRepository.appThemeStateFlow
         }
+    }
+
+    @Test
+    fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
+        val viewModel = createViewModel()
         val mockIntent = mockk<Intent> {
             every { data?.host } returns "captcha-callback"
             every { data?.getQueryParameter("token") } returns "mockk_token"
             every { action } returns Intent.ACTION_VIEW
         }
-        val viewModel = MainViewModel(
-            authRepository = authRepository,
-        )
-        viewModel.sendAction(
+        viewModel.trySendAction(
             MainAction.ReceiveNewIntent(
                 intent = mockIntent,
             ),
@@ -57,8 +87,28 @@ class MainViewModelTest {
         }
     }
 
+    private fun createViewModel() = MainViewModel(
+        authRepository = authRepository,
+        settingsRepository = settingsRepository,
+    )
+
     companion object {
-        private const val LOGIN_RESULT_PATH =
-            "com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt"
+        private const val USER_ID = "userID"
+        private val DEFAULT_USER_STATE = UserState(
+            activeUserId = USER_ID,
+            accounts = listOf(
+                UserState.Account(
+                    userId = USER_ID,
+                    name = "Active User",
+                    email = "active@bitwarden.com",
+                    environment = Environment.Us,
+                    avatarColorHex = "#aa00aa",
+                    isPremium = true,
+                    isLoggedIn = true,
+                    isVaultUnlocked = true,
+                    organizations = emptyList(),
+                ),
+            ),
+        )
     }
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt
index 1dbc06224..22a0964d4 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
 import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
 import com.x8bit.bitwarden.ui.platform.base.util.isLightOverlayRequired
 import com.x8bit.bitwarden.ui.platform.base.util.toSafeOverlayColor
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
@@ -25,7 +26,7 @@ class ColorExtensionsTest : BaseComposeTest() {
 
     @Test
     fun `toSafeOverlayColor for a dark color in light mode should use the surface color`() =
-        runTestWithTheme(isDarkTheme = false) {
+        runTestWithTheme(theme = AppTheme.LIGHT) {
             assertEquals(
                 MaterialTheme.colorScheme.surface,
                 Color.Blue.toSafeOverlayColor(),
@@ -34,7 +35,7 @@ class ColorExtensionsTest : BaseComposeTest() {
 
     @Test
     fun `toSafeOverlayColor for a dark color in dark mode should use the onSurface color`() =
-        runTestWithTheme(isDarkTheme = true) {
+        runTestWithTheme(theme = AppTheme.DARK) {
             assertEquals(
                 MaterialTheme.colorScheme.onSurface,
                 Color.Blue.toSafeOverlayColor(),
@@ -43,7 +44,7 @@ class ColorExtensionsTest : BaseComposeTest() {
 
     @Test
     fun `toSafeOverlayColor for a light color in light mode should use the onSurface color`() =
-        runTestWithTheme(isDarkTheme = false) {
+        runTestWithTheme(theme = AppTheme.LIGHT) {
             assertEquals(
                 MaterialTheme.colorScheme.onSurface,
                 Color.Yellow.toSafeOverlayColor(),
@@ -52,7 +53,7 @@ class ColorExtensionsTest : BaseComposeTest() {
 
     @Test
     fun `toSafeOverlayColor for a light color in dark mode should use the surface color`() =
-        runTestWithTheme(isDarkTheme = true) {
+        runTestWithTheme(theme = AppTheme.DARK) {
             assertEquals(
                 MaterialTheme.colorScheme.surface,
                 Color.Yellow.toSafeOverlayColor(),
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt
index 9e3eda631..fa14da21b 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt
@@ -5,6 +5,7 @@ import app.cash.turbine.test
 import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Assertions.assertFalse
@@ -106,6 +107,70 @@ class SettingsDiskSourceTest {
         )
     }
 
+    @Test
+    fun `appTheme when values are present should pull from SharedPreferences`() {
+        val appThemeBaseKey = "bwPreferencesStorage:appTheme"
+        val appTheme = AppTheme.DEFAULT
+        fakeSharedPreferences
+            .edit {
+                putString(
+                    appThemeBaseKey,
+                    appTheme.value,
+                )
+            }
+        val actual = settingsDiskSource.appTheme
+        assertEquals(
+            appTheme,
+            actual,
+        )
+    }
+
+    @Test
+    fun `appTheme when values are absent should return DEFAULT`() {
+        assertEquals(
+            AppTheme.DEFAULT,
+            settingsDiskSource.appTheme,
+        )
+    }
+
+    @Test
+    fun `getAppThemeFlow should react to changes in getAppTheme`() = runTest {
+        val appTheme = AppTheme.DARK
+        settingsDiskSource.appThemeFlow.test {
+            // The initial values of the Flow and the property are in sync
+            assertEquals(
+                AppTheme.DEFAULT,
+                settingsDiskSource.appTheme,
+            )
+            assertEquals(
+                AppTheme.DEFAULT,
+                awaitItem(),
+            )
+
+            // Updating the repository updates shared preferences
+            settingsDiskSource.appTheme = appTheme
+            assertEquals(
+                appTheme,
+                awaitItem(),
+            )
+        }
+    }
+
+    @Test
+    fun `storeAppTheme for should update SharedPreferences`() {
+        val appThemeBaseKey = "bwPreferencesStorage:theme"
+        val appTheme = AppTheme.DARK
+        settingsDiskSource.appTheme = appTheme
+        val actual = fakeSharedPreferences.getString(
+            appThemeBaseKey,
+            null,
+        )
+        assertEquals(
+            appTheme.value,
+            actual,
+        )
+    }
+
     @Test
     fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() {
         val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout"
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
index 4ff9679ce..769b0815c 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
@@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
 import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.onSubscription
@@ -13,6 +14,9 @@ import kotlinx.coroutines.flow.onSubscription
  */
 class FakeSettingsDiskSource : SettingsDiskSource {
 
+    private val mutableAppThemeFlow =
+        bufferedMutableSharedFlow<AppTheme>(replay = 1)
+
     private val mutableVaultTimeoutActionsFlowMap =
         mutableMapOf<String, MutableSharedFlow<VaultTimeoutAction?>>()
 
@@ -25,6 +29,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
     private val mutableIsIconLoadingDisabled =
         bufferedMutableSharedFlow<Boolean?>()
 
+    private var storedAppTheme: AppTheme = AppTheme.DEFAULT
     private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
     private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
 
@@ -34,6 +39,18 @@ class FakeSettingsDiskSource : SettingsDiskSource {
 
     override var appLanguage: AppLanguage? = null
 
+    override var appTheme: AppTheme
+        get() = storedAppTheme
+        set(value) {
+            storedAppTheme = value
+            mutableAppThemeFlow.tryEmit(value)
+        }
+
+    override val appThemeFlow: Flow<AppTheme>
+        get() = mutableAppThemeFlow.onSubscription {
+            emit(appTheme)
+        }
+
     override var isIconLoadingDisabled: Boolean?
         get() = storedIsIconLoadingDisabled
         set(value) {
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
index ed8d9fff2..058f29f68 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
 import com.x8bit.bitwarden.data.platform.util.asSuccess
 import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import io.mockk.coEvery
 import io.mockk.coVerify
 import io.mockk.mockk
@@ -130,6 +131,57 @@ class SettingsRepositoryTest {
         assertFalse(fakeSettingsDiskSource.isIconLoadingDisabled!!)
     }
 
+    @Test
+    fun `appTheme should pull from and update SettingsDiskSource`() {
+        fakeAuthDiskSource.userState = null
+        assertEquals(
+            AppTheme.DEFAULT,
+            settingsRepository.appTheme,
+        )
+
+        fakeAuthDiskSource.userState = MOCK_USER_STATE
+
+        // Updates to the disk source change the repository value
+        fakeSettingsDiskSource.appTheme = AppTheme.DARK
+        assertEquals(
+            AppTheme.DARK,
+            settingsRepository.appTheme,
+        )
+
+        // Updates to the repository value change the disk source
+        settingsRepository.appTheme = AppTheme.LIGHT
+        assertEquals(
+            AppTheme.LIGHT,
+            fakeSettingsDiskSource.appTheme,
+        )
+    }
+
+    @Test
+    fun `getAppThemeFlow should react to changes in SettingsDiskSource`() = runTest {
+        settingsRepository
+            .appThemeStateFlow
+            .test {
+                assertEquals(
+                    AppTheme.DEFAULT,
+                    awaitItem(),
+                )
+                fakeSettingsDiskSource.appTheme = AppTheme.DARK
+                assertEquals(
+                    AppTheme.DARK,
+                    awaitItem(),
+                )
+            }
+    }
+
+    @Test
+    fun `storeAppTheme should properly update SettingsDiskSource`() {
+        settingsRepository.appTheme = AppTheme.DARK
+        assertEquals(
+            AppTheme.DARK,
+            fakeSettingsDiskSource.appTheme,
+        )
+    }
+
     @Test
     fun `vaultTimeout should pull from and update SettingsDiskSource for the current user`() {
         fakeAuthDiskSource.userState = null
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt
index db99083f2..16f094e9d 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.base
 
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.test.junit4.createComposeRule
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
 import org.junit.Rule
 
@@ -18,12 +19,12 @@ abstract class BaseComposeTest : BaseRobolectricTest() {
      * with the [BitwardenTheme].
      */
     protected fun runTestWithTheme(
-        isDarkTheme: Boolean,
+        theme: AppTheme,
         test: @Composable () -> Unit,
     ) {
         composeTestRule.setContent {
             BitwardenTheme(
-                darkTheme = isDarkTheme,
+                theme = theme,
             ) {
                 test()
             }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt
index cf5e8c2fb..47bd196ab 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
 import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
 import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.util.assertNoDialogExists
 import io.mockk.every
 import io.mockk.mockk
@@ -121,7 +122,7 @@ class AppearanceScreenTest : BaseComposeTest() {
         verify {
             viewModel.trySendAction(
                 AppearanceAction.ThemeChange(
-                    theme = AppearanceState.Theme.DARK,
+                    theme = AppTheme.DARK,
                 ),
             )
         }
@@ -153,5 +154,5 @@ class AppearanceScreenTest : BaseComposeTest() {
 private val DEFAULT_STATE = AppearanceState(
     language = AppLanguage.DEFAULT,
     showWebsiteIcons = false,
-    theme = AppearanceState.Theme.DEFAULT,
+    theme = AppTheme.DEFAULT,
 )
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt
index ff5078c45..c26ab608e 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt
@@ -6,6 +6,7 @@ import app.cash.turbine.test
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import io.mockk.every
 import io.mockk.just
 import io.mockk.mockk
@@ -22,9 +23,11 @@ import org.junit.jupiter.api.Test
 class AppearanceViewModelTest : BaseViewModelTest() {
     private val mockSettingsRepository = mockk<SettingsRepository> {
         every { appLanguage } returns AppLanguage.DEFAULT
+        every { appTheme } returns AppTheme.DEFAULT
         every { appLanguage = AppLanguage.ENGLISH } just runs
         every { isIconLoadingDisabled } returns false
         every { isIconLoadingDisabled = true } just runs
+        every { appTheme = AppTheme.DARK } just runs
     }
 
     @BeforeEach
@@ -45,7 +48,7 @@ class AppearanceViewModelTest : BaseViewModelTest() {
 
     @Test
     fun `initial state should be correct when set`() {
-        val state = DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK)
+        val state = DEFAULT_STATE.copy(theme = AppTheme.DARK)
         val viewModel = createViewModel(state = state)
         assertEquals(state, viewModel.stateFlow.value)
     }
@@ -110,18 +113,24 @@ class AppearanceViewModelTest : BaseViewModelTest() {
     }
 
     @Test
-    fun `on ThemeChange should update state`() = runTest {
+    fun `on ThemeChange should update state and set theme in SettingsRepository`() = runTest {
         val viewModel = createViewModel()
         viewModel.stateFlow.test {
             assertEquals(
                 DEFAULT_STATE,
                 awaitItem(),
             )
-            viewModel.trySendAction(AppearanceAction.ThemeChange(AppearanceState.Theme.DARK))
+
+            viewModel.trySendAction(AppearanceAction.ThemeChange(AppTheme.DARK))
             assertEquals(
-                DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK),
+                DEFAULT_STATE.copy(theme = AppTheme.DARK),
                 awaitItem(),
             )
+
+            verify {
+                mockSettingsRepository.appTheme
+                mockSettingsRepository.appTheme = AppTheme.DARK
+            }
         }
     }
 
@@ -139,7 +148,7 @@ class AppearanceViewModelTest : BaseViewModelTest() {
         private val DEFAULT_STATE = AppearanceState(
             language = AppLanguage.DEFAULT,
             showWebsiteIcons = true,
-            theme = AppearanceState.Theme.DEFAULT,
+            theme = AppTheme.DEFAULT,
         )
     }
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt
new file mode 100644
index 000000000..708725621
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt
@@ -0,0 +1,24 @@
+package com.x8bit.bitwarden.ui.platform.util
+
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.ui.platform.base.util.asText
+import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class AppThemeExtensionsTest {
+    @Test
+    fun `displayLabel should return the correct value for each type`() {
+        mapOf(
+            AppTheme.DEFAULT to R.string.default_system.asText(),
+            AppTheme.DARK to R.string.dark.asText(),
+            AppTheme.LIGHT to R.string.light.asText(),
+        )
+            .forEach { (type, label) ->
+                assertEquals(
+                    label,
+                    type.displayLabel,
+                )
+            }
+    }
+}