From b6a165aef5eaf59aea391b3130cf2c852022a579 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Sun, 14 Apr 2024 17:03:34 -0400 Subject: [PATCH] Allow modification of appearance settings (#26) --- .../datasource/disk/SettingsDiskSource.kt | 16 ++ .../datasource/disk/SettingsDiskSourceImpl.kt | 29 +++ .../platform/repository/SettingsRepository.kt | 19 +- .../repository/SettingsRepositoryImpl.kt | 24 ++ .../components/row/BitwardenTextRow.kt | 90 +++++++ .../components/toggle/BitwardenWideSwitch.kt | 130 +++++++++++ .../feature/settings/SettingsScreen.kt | 219 ++++++++++++------ .../feature/settings/SettingsViewModel.kt | 132 +++++++++-- .../settings/appearance/model/AppLanguage.kt | 178 ++++++++++++++ .../ui/platform/util/AppThemeExtensions.kt | 16 ++ app/src/main/res/values/strings.xml | 10 + 11 files changed, 771 insertions(+), 92 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index 3dc413c67..df4625b87 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -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 + /** + * 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 + /** * Stores the threshold at which users are alerted that an items validity period is nearing * expiration. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 1cb06dfdc..fbbc913dd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -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>() + private val mutableIsIconLoadingDisabledFlow = + bufferedMutableSharedFlow() + private val mutableAlertThresholdSecondsFlow = bufferedMutableSharedFlow() + 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 + get() = mutableIsIconLoadingDisabledFlow + .onSubscription { emit(getBoolean(DISABLE_ICON_LOADING_KEY)) } + override fun storeAlertThresholdSeconds(thresholdSeconds: Int) { putInt( ALERT_THRESHOLD_SECONDS_KEY, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 922b1fcd0..4be1f5798 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -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 + + /** + * The current setting for getting login item icons. + */ + var isIconLoadingDisabled: Boolean + + /** + * Emits updates that track the [isIconLoadingDisabled] value. + */ + val isIconLoadingDisabledFlow: Flow } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 38b7f19a6..00d2a90eb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -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 + get() = settingsDiskSource + .isIconLoadingDisabledFlow + .map { it ?: false } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource + .isIconLoadingDisabled + ?: false, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt new file mode 100644 index 000000000..2d70d638b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt new file mode 100644 index 000000000..e0568123e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/toggle/BitwardenWideSwitch.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index d6296ab69..7178d41e4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -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,16 +67,114 @@ fun SettingsScreen( .fillMaxSize() .verticalScroll(state = rememberScrollState()) ) { - Settings.entries.forEach { - SettingsRow( - text = it.text, - onClick = remember(viewModel) { - { viewModel.trySendAction(SettingsAction.SettingsClick(it)) } + 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 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, +) { + 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( + text = currentSelection.text(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (shouldShowLanguageSelectionDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.language), + onDismissRequest = { shouldShowLanguageSelectionDialog = false }, + ) { + 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), + ) }, - modifier = Modifier - .semantics { testTag = it.testTag } - .padding(horizontal = 16.dp) - .fillMaxWidth(), ) } } @@ -83,57 +182,41 @@ fun SettingsScreen( } @Composable -private fun SettingsRow( - text: Text, - onClick: () -> Unit, +private fun ThemeSelectionRow( + currentSelection: AppTheme, + onThemeSelection: (AppTheme) -> 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 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( - 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.displayLabel(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } -} -@Preview -@Composable -private fun SettingsRows_preview() { - AuthenticatorTheme { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize(), + if (shouldShowThemeSelectionDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.theme), + onDismissRequest = { shouldShowThemeSelectionDialog = false }, ) { - Settings.entries.forEach { - SettingsRow( - text = it.text, - onClick = { }, + AppTheme.entries.forEach { option -> + BitwardenSelectionRow( + text = option.displayLabel, + isSelected = option == currentSelection, + onClick = { + shouldShowThemeSelectionDialog = false + onThemeSelection( + AppTheme.entries.first { it == option }, + ) + }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 3040aebf0..a5e039b42 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -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( - initialState = Unit + savedStateHandle: SavedStateHandle, + val settingsRepository: SettingsRepository, +) : BaseViewModel( + 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 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 handleSettingClick(action: SettingsAction.SettingsClick) { - when (action.setting) { - else -> {} + 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() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt new file mode 100644 index 000000000..df7c672b0 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/feature/settings/appearance/model/AppLanguage.kt @@ -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(), + ), +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt new file mode 100644 index 000000000..530e96804 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/util/AppThemeExtensions.kt @@ -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() + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5d763931..ad2420f2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,4 +64,14 @@ Options Try again Not yet implemented + Appearance + Default (System) + Theme + Change the application\'s color theme. + Dark + Light + Language + The language has been changed to %1$s. Please restart the app to see the change + Show website icons + Show a recognizable image next to each login.