Allow modification of appearance settings (#26)

This commit is contained in:
Patrick Honkonen
2024-04-14 17:03:34 -04:00
committed by GitHub
parent 6b025832d7
commit b6a165aef5
11 changed files with 774 additions and 95 deletions

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.authenticator.data.platform.datasource.disk
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.flow.Flow
@@ -8,6 +9,11 @@ import kotlinx.coroutines.flow.Flow
*/
interface SettingsDiskSource {
/**
* The currently persisted app language (or `null` if not set).
*/
var appLanguage: AppLanguage?
/**
* The currently persisted app theme (or `null` if not set).
*/
@@ -18,6 +24,16 @@ interface SettingsDiskSource {
*/
val appThemeFlow: Flow<AppTheme>
/**
* The currently persisted setting for getting login item icons (or `null` if not set).
*/
var isIconLoadingDisabled: Boolean?
/**
* Emits updates that track [isIconLoadingDisabled].
*/
val isIconLoadingDisabledFlow: Flow<Boolean?>
/**
* Stores the threshold at which users are alerted that an items validity period is nearing
* expiration.

View File

@@ -3,15 +3,18 @@ package com.x8bit.bitwarden.authenticator.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
import com.x8bit.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.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_THEME_KEY = "$BASE_KEY:theme"
private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale"
private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds"
private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon"
/**
* Primary implementation of [SettingsDiskSource].
@@ -26,9 +29,24 @@ class SettingsDiskSourceImpl(
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow =
bufferedMutableSharedFlow<Boolean?>()
private val mutableAlertThresholdSecondsFlow =
bufferedMutableSharedFlow<Int>()
override var appLanguage: AppLanguage?
get() = getString(key = APP_LANGUAGE_KEY)
?.let { storedValue ->
AppLanguage.entries.firstOrNull { storedValue == it.localeName }
}
set(value) {
putString(
key = APP_LANGUAGE_KEY,
value = value?.localeName,
)
}
override var appTheme: AppTheme
get() = getString(key = APP_THEME_KEY)
?.let { storedValue ->
@@ -47,6 +65,17 @@ class SettingsDiskSourceImpl(
get() = mutableAppThemeFlow
.onSubscription { emit(appTheme) }
override var isIconLoadingDisabled: Boolean?
get() = getBoolean(key = DISABLE_ICON_LOADING_KEY)
set(value) {
putBoolean(key = DISABLE_ICON_LOADING_KEY, value = value)
mutableIsIconLoadingDisabledFlow.tryEmit(value)
}
override val isIconLoadingDisabledFlow: Flow<Boolean?>
get() = mutableIsIconLoadingDisabledFlow
.onSubscription { emit(getBoolean(DISABLE_ICON_LOADING_KEY)) }
override fun storeAlertThresholdSeconds(thresholdSeconds: Int) {
putInt(
ALERT_THRESHOLD_SECONDS_KEY,

View File

@@ -1,6 +1,8 @@
package com.x8bit.bitwarden.authenticator.data.platform.repository
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
@@ -8,6 +10,11 @@ import kotlinx.coroutines.flow.StateFlow
*/
interface SettingsRepository {
/**
* The [AppLanguage] for the current user.
*/
var appLanguage: AppLanguage
/**
* The currently stored [AppTheme].
*/
@@ -21,10 +28,20 @@ interface SettingsRepository {
/**
* The currently stored expiration alert threshold.
*/
var authenticatorAlertThresholdSeconds : Int
var authenticatorAlertThresholdSeconds: Int
/**
* Tracks changes to the expiration alert threshold.
*/
val authenticatorAlertThresholdSecondsFlow: StateFlow<Int>
/**
* The current setting for getting login item icons.
*/
var isIconLoadingDisabled: Boolean
/**
* Emits updates that track the [isIconLoadingDisabled] value.
*/
val isIconLoadingDisabledFlow: Flow<Boolean>
}

View File

@@ -2,8 +2,10 @@ package com.x8bit.bitwarden.authenticator.data.platform.repository
import com.x8bit.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.authenticator.data.platform.manager.DispatcherManager
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -19,6 +21,12 @@ class SettingsRepositoryImpl(
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override var appLanguage: AppLanguage
get() = settingsDiskSource.appLanguage ?: AppLanguage.DEFAULT
set(value) {
settingsDiskSource.appLanguage = value
}
override var appTheme: AppTheme by settingsDiskSource::appTheme
override var authenticatorAlertThresholdSeconds = settingsDiskSource.getAlertThresholdSeconds()
@@ -41,5 +49,21 @@ class SettingsRepositoryImpl(
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource.getAlertThresholdSeconds(),
)
override var isIconLoadingDisabled: Boolean
get() = settingsDiskSource.isIconLoadingDisabled ?: false
set(value) {
settingsDiskSource.isIconLoadingDisabled = value
}
override val isIconLoadingDisabledFlow: StateFlow<Boolean>
get() = settingsDiskSource
.isIconLoadingDisabledFlow
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.isIconLoadingDisabled
?: false,
)
}

View File

@@ -0,0 +1,90 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.row
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* Represents a clickable row of text and can contains an optional [content] that appears to the
* right of the [text].
*
* @param text The label for the row as a [String].
* @param onClick The callback when the row is clicked.
* @param modifier The modifier to be applied to the layout.
* @param description An optional description label to be displayed below the [text].
* @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults
* to `false`.
* @param content The content of the [BitwardenTextRow].
*/
@Composable
fun BitwardenTextRow(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
withDivider: Boolean = false,
content: (@Composable () -> Unit)? = null,
) {
Box(
contentAlignment = Alignment.BottomCenter,
modifier = modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.semantics(mergeDescendants = true) { },
) {
Row(
modifier = Modifier
.defaultMinSize(minHeight = 56.dp)
.padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.padding(end = 16.dp)
.weight(1f),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
content?.invoke()
}
if (withDivider) {
HorizontalDivider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
}

View File

@@ -0,0 +1,130 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.toggle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
/**
* A wide custom switch composable
*
* @param label The descriptive text label to be displayed adjacent to the switch.
* @param isChecked The current state of the switch (either checked or unchecked).
* @param onCheckedChange A lambda that is invoked when the switch's state changes.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
* @param description An optional description label to be displayed below the [label].
* @param contentDescription A description of the switch's UI for accessibility purposes.
* @param readOnly Disables the click functionality without modifying the other UI characteristics.
* @param enabled Whether or not this switch is enabled. This is similar to setting [readOnly] but
* comes with some additional visual changes.
*/
@Composable
fun BitwardenWideSwitch(
label: String,
isChecked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
description: String? = null,
contentDescription: String? = null,
readOnly: Boolean = false,
enabled: Boolean = true,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.wrapContentHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange?.invoke(!isChecked) },
enabled = !readOnly && enabled,
)
.semantics(mergeDescendants = true) {
toggleableState = ToggleableState(isChecked)
contentDescription?.let { this.contentDescription = it }
}
.then(modifier),
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.outline
},
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.outline
},
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
modifier = Modifier
.height(56.dp),
checked = isChecked,
onCheckedChange = null,
)
}
}
@Preview
@Composable
private fun BitwardenWideSwitch_preview_isChecked() {
AuthenticatorTheme {
BitwardenWideSwitch(
label = "Label",
isChecked = true,
onCheckedChange = {},
)
}
}
@Preview
@Composable
private fun BitwardenWideSwitch_preview_isNotChecked() {
AuthenticatorTheme {
BitwardenWideSwitch(
label = "Label",
isChecked = false,
onCheckedChange = {},
)
}
}

View File

@@ -1,43 +1,44 @@
package com.x8bit.bitwarden.authenticator.ui.platform.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionRow
import com.x8bit.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.authenticator.ui.platform.components.row.BitwardenTextRow
import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
import com.x8bit.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.authenticator.ui.platform.util.displayLabel
/**
* Display the settings screen.
@@ -47,7 +48,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
@@ -66,74 +67,156 @@ fun SettingsScreen(
.fillMaxSize()
.verticalScroll(state = rememberScrollState())
) {
Settings.entries.forEach {
SettingsRow(
text = it.text,
onClick = remember(viewModel) {
{ viewModel.trySendAction(SettingsAction.SettingsClick(it)) }
},
modifier = Modifier
.semantics { testTag = it.testTag }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
AppearanceSettings(
state = state,
onLanguageSelection = remember(viewModel) {
{
viewModel.trySendAction(SettingsAction.AppearanceChange.LanguageChange(it))
}
},
onThemeSelection = remember(viewModel) {
{ viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it)) }
},
onShowWebsiteIconsChange = remember(viewModel) {
{
viewModel.trySendAction(
SettingsAction.AppearanceChange.ShowWebsiteIconsChange(
it
)
)
}
},
)
}
}
}
@Composable
private fun SettingsRow(
text: Text,
onClick: () -> Unit,
private fun AppearanceSettings(
state: SettingsState,
onLanguageSelection: (language: AppLanguage) -> Unit,
onThemeSelection: (theme: AppTheme) -> Unit,
onShowWebsiteIconsChange: (showWebsiteIcons: Boolean) -> Unit,
) {
BitwardenListHeaderText(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.appearance)
)
LanguageSelectionRow(
currentSelection = state.appearance.language,
onLanguageSelection = onLanguageSelection,
modifier = Modifier
.semantics { testTag = "LanguageChooser" }
.fillMaxWidth(),
)
ThemeSelectionRow(
currentSelection = state.appearance.theme,
onThemeSelection = onThemeSelection,
modifier = Modifier
.semantics { testTag = "ThemeChooser" }
.fillMaxWidth(),
)
BitwardenWideSwitch(
label = stringResource(id = R.string.show_website_icons),
description = stringResource(id = R.string.show_website_icons_description),
isChecked = state.appearance.showWebsiteIcons,
onCheckedChange = onShowWebsiteIconsChange,
modifier = Modifier
.semantics { testTag = "ShowWebsiteIconsSwitch" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
@Composable
private fun LanguageSelectionRow(
currentSelection: AppLanguage,
onLanguageSelection: (AppLanguage) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.bottomDivider(paddingStart = 16.dp)
.defaultMinSize(minHeight = 56.dp)
.padding(end = 8.dp, top = 8.dp, bottom = 8.dp)
.then(modifier),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
var languageChangedDialogState: BasicDialogState by rememberSaveable {
mutableStateOf(BasicDialogState.Hidden)
}
var shouldShowLanguageSelectionDialog by rememberSaveable { mutableStateOf(false) }
BitwardenBasicDialog(
visibilityState = languageChangedDialogState,
onDismissRequest = { languageChangedDialogState = BasicDialogState.Hidden },
)
BitwardenTextRow(
text = stringResource(id = R.string.language),
onClick = { shouldShowLanguageSelectionDialog = true },
modifier = modifier,
) {
Text(
modifier = Modifier
.padding(end = 16.dp)
.weight(1f),
text = text(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Icon(
painter = painterResource(id = R.drawable.ic_navigate_next),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.mirrorIfRtl()
.size(24.dp),
text = currentSelection.text(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview
@Composable
private fun SettingsRows_preview() {
AuthenticatorTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
if (shouldShowLanguageSelectionDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.language),
onDismissRequest = { shouldShowLanguageSelectionDialog = false },
) {
Settings.entries.forEach {
SettingsRow(
text = it.text,
onClick = { },
AppLanguage.entries.forEach { option ->
BitwardenSelectionRow(
text = option.text,
isSelected = option == currentSelection,
onClick = {
shouldShowLanguageSelectionDialog = false
onLanguageSelection(option)
languageChangedDialogState = BasicDialogState.Shown(
title = R.string.language.asText(),
message = R.string.language_change_x_description.asText(option.text),
)
},
)
}
}
}
}
@Composable
private fun ThemeSelectionRow(
currentSelection: AppTheme,
onThemeSelection: (AppTheme) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowThemeSelectionDialog by remember { mutableStateOf(false) }
BitwardenTextRow(
text = stringResource(id = R.string.theme),
description = stringResource(id = R.string.theme_description),
onClick = { shouldShowThemeSelectionDialog = true },
modifier = modifier,
) {
Text(
text = currentSelection.displayLabel(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (shouldShowThemeSelectionDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.theme),
onDismissRequest = { shouldShowThemeSelectionDialog = false },
) {
AppTheme.entries.forEach { option ->
BitwardenSelectionRow(
text = option.displayLabel,
isSelected = option == currentSelection,
onClick = {
shouldShowThemeSelectionDialog = false
onThemeSelection(
AppTheme.entries.first { it == option },
)
},
)
}
}

View File

@@ -1,30 +1,108 @@
package com.x8bit.bitwarden.authenticator.ui.platform.feature.settings
import androidx.compose.material3.Text
import android.os.Parcelable
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the settings screen.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
) : BaseViewModel<Unit, SettingsEvent, SettingsAction>(
initialState = Unit
savedStateHandle: SavedStateHandle,
val settingsRepository: SettingsRepository,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
initialState = savedStateHandle[KEY_STATE]
?: SettingsState(
appearance = SettingsState.Appearance(
language = settingsRepository.appLanguage,
theme = settingsRepository.appTheme,
showWebsiteIcons = !settingsRepository.isIconLoadingDisabled,
),
),
) {
override fun handleAction(action: SettingsAction) {
when (action) {
is SettingsAction.SettingsClick -> handleSettingClick(action)
is SettingsAction.AppearanceChange -> handleAppearanceChange(action)
}
}
private fun handleSettingClick(action: SettingsAction.SettingsClick) {
when (action.setting) {
else -> {}
private fun handleAppearanceChange(action: SettingsAction.AppearanceChange) {
when (action) {
is SettingsAction.AppearanceChange.LanguageChange -> {
handleLanguageChange(action.language)
}
is SettingsAction.AppearanceChange.ShowWebsiteIconsChange -> {
handleShowWebsiteIconsChange(action.showWebsiteIcons)
}
is SettingsAction.AppearanceChange.ThemeChange -> {
handleThemeChange(action.appTheme)
}
}
}
private fun handleLanguageChange(language: AppLanguage) {
mutableStateFlow.update {
it.copy(
appearance = it.appearance.copy(language = language)
)
}
settingsRepository.appLanguage = language
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(
language.localeName,
)
AppCompatDelegate.setApplicationLocales(appLocale)
}
private fun handleShowWebsiteIconsChange(showWebsiteIcons: Boolean) {
mutableStateFlow.update {
it.copy(
appearance = it.appearance.copy(showWebsiteIcons = showWebsiteIcons)
)
}
// Negate the boolean to properly update the settings repository
settingsRepository.isIconLoadingDisabled = !showWebsiteIcons
}
private fun handleThemeChange(theme: AppTheme) {
mutableStateFlow.update {
it.copy(
appearance = it.appearance.copy(theme = theme)
)
}
settingsRepository.appTheme = theme
}
}
/**
* Models state of the Settings screen.
*/
@Parcelize
data class SettingsState(
val appearance: Appearance,
) : Parcelable {
/**
* Models state of the Appearance settings.
*/
@Parcelize
data class Appearance(
val language: AppLanguage,
val theme: AppTheme,
val showWebsiteIcons: Boolean,
) : Parcelable
}
/**
@@ -36,19 +114,27 @@ sealed class SettingsEvent
* Models actions for the settings screen.
*/
sealed class SettingsAction {
/**
* User clicked a settings row.
*/
class SettingsClick(val setting: Settings) : SettingsAction()
}
/**
* Enum representing the settings rows, such as "import" or "export".
*
* @property text The [Text] of the string that represents the label of each setting.
* @property testTag The value that should be set for the test tag. This is used in Appium testing.
*/
enum class Settings(
val text: Text,
val testTag: String,
)
sealed class AppearanceChange : SettingsAction() {
/**
* Indicates the user changed the language.
*/
data class LanguageChange(
val language: AppLanguage,
) : AppearanceChange()
/**
* Indicates the user toggled the Show Website Icons switch to [showWebsiteIcons].
*/
data class ShowWebsiteIconsChange(
val showWebsiteIcons: Boolean,
) : AppearanceChange()
/**
* Indicates the user selected a new theme.
*/
data class ThemeChange(
val appTheme: AppTheme,
) : AppearanceChange()
}
}

View File

@@ -0,0 +1,178 @@
package com.x8bit.bitwarden.authenticator.ui.platform.feature.settings.appearance.model
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
/**
* Represents the languages supported by the app.
*/
enum class AppLanguage(
val localeName: String?,
val text: Text,
) {
DEFAULT(
localeName = null,
text = R.string.default_system.asText(),
),
AFRIKAANS(
localeName = "af",
text = "Afrikaans".asText(),
),
BELARUSIAN(
localeName = "be",
text = "Беларуская".asText(),
),
BULGARIAN(
localeName = "bg",
text = "български".asText(),
),
CATALAN(
localeName = "ca",
text = "català".asText(),
),
CZECH(
localeName = "cs",
text = "čeština".asText(),
),
DANISH(
localeName = "da",
text = "Dansk".asText(),
),
GERMAN(
localeName = "de",
text = "Deutsch".asText(),
),
GREEK(
localeName = "el",
text = "Ελληνικά".asText(),
),
ENGLISH(
localeName = "en",
text = "English".asText(),
),
ENGLISH_BRITISH(
localeName = "en-GB",
text = "English (British)".asText(),
),
SPANISH(
localeName = "es",
text = "Español".asText(),
),
ESTONIAN(
localeName = "et",
text = "eesti".asText(),
),
PERSIAN(
localeName = "fa",
text = "فارسی".asText(),
),
FINNISH(
localeName = "fi",
text = "suomi".asText(),
),
FRENCH(
localeName = "fr",
text = "Français".asText(),
),
HINDI(
localeName = "hi",
text = "हिन्दी".asText(),
),
CROATIAN(
localeName = "hr",
text = "hrvatski".asText(),
),
HUNGARIAN(
localeName = "hu",
text = "magyar".asText(),
),
INDONESIAN(
localeName = "in",
text = "Bahasa Indonesia".asText(),
),
ITALIAN(
localeName = "it",
text = "Italiano".asText(),
),
HEBREW(
localeName = "iw",
text = "עברית".asText(),
),
JAPANESE(
localeName = "ja",
text = "日本語".asText(),
),
KOREAN(
localeName = "ko",
text = "한국어".asText(),
),
LATVIAN(
localeName = "lv",
text = "Latvietis".asText(),
),
MALAYALAM(
localeName = "ml",
text = "മലയാളം".asText(),
),
NORWEGIAN(
localeName = "nb",
text = "norsk (bokmål)".asText(),
),
DUTCH(
localeName = "nl",
text = "Nederlands".asText(),
),
POLISH(
localeName = "pl",
text = "Polski".asText(),
),
PORTUGUESE_BRAZILIAN(
localeName = "pt-BR",
text = "Português do Brasil".asText(),
),
PORTUGUESE(
localeName = "pt-PT",
text = "Português".asText(),
),
ROMANIAN(
localeName = "ro",
text = "română".asText(),
),
RUSSIAN(
localeName = "ru",
text = "русский".asText(),
),
SLOVAK(
localeName = "sk",
text = "slovenčina".asText(),
),
SWEDISH(
localeName = "sv",
text = "svenska".asText(),
),
THAI(
localeName = "th",
text = "ไทย".asText(),
),
TURKISH(
localeName = "tr",
text = "Türkçe".asText(),
),
UKRAINIAN(
localeName = "uk",
text = "українська".asText(),
),
VIETNAMESE(
localeName = "vi",
text = "Tiếng Việt".asText(),
),
CHINESE_SIMPLIFIED(
localeName = "zh-CN",
text = "中文(中国大陆)".asText(),
),
CHINESE_TRADITIONAL(
localeName = "zh-TW",
text = "中文(台灣)".asText(),
),
}

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.authenticator.ui.platform.util
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.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()
}

View File

@@ -64,4 +64,14 @@
<string name="options">Options</string>
<string name="try_again">Try again</string>
<string name="not_yet_implemented">Not yet implemented</string>
<string name="appearance">Appearance</string>
<string name="default_system">Default (System)</string>
<string name="theme">Theme</string>
<string name="theme_description">Change the application\'s color theme.</string>
<string name="dark">Dark</string>
<string name="light">Light</string>
<string name="language">Language</string>
<string name="language_change_x_description">The language has been changed to %1$s. Please restart the app to see the change</string>
<string name="show_website_icons">Show website icons</string>
<string name="show_website_icons_description">Show a recognizable image next to each login.</string>
</resources>