BIT-1327: Add support for theme selection (#641)

This commit is contained in:
Caleb Derosier
2024-01-16 16:41:34 -07:00
committed by Álison Fernandes
parent 21a9802ed4
commit 74fac97257
21 changed files with 431 additions and 80 deletions

View File

@@ -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,
),

View File

@@ -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()
}
}

View File

@@ -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).
*/

View File

@@ -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) {

View File

@@ -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.
*/

View File

@@ -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) {

View File

@@ -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 = {},
)

View File

@@ -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 },
)
},
)

View File

@@ -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()
}

View File

@@ -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"),
}

View File

@@ -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 {

View File

@@ -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()
}