mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 20:54:58 -05:00
Allow modification of appearance settings (#26)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user