From a23fc319de197f31728521cc3768ef93dd2456cf Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:44:59 -0400 Subject: [PATCH] PM-13943 : PT1 Custom snackbar UI (#4135) --- .../MasterPasswordGeneratorScreen.kt | 14 +-- .../button/BitwardenOutlinedButton.kt | 20 +++- .../button/BitwardenOutlinedButtonWithIcon.kt | 7 +- .../button/color/BitwardenButtonColors.kt | 22 +++- .../components/snackbar/BitwardenSnackbar.kt | 111 ++++++++++++++++++ .../snackbar/BitwardenSnackbarHost.kt | 31 +++-- .../snackbar/BitwardenSnackbarHostState.kt | 71 +++++++++++ .../feature/generator/GeneratorScreen.kt | 12 +- 8 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordgenerator/MasterPasswordGeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordgenerator/MasterPasswordGeneratorScreen.kt index 02248840bf7..df528b746d8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordgenerator/MasterPasswordGeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordgenerator/MasterPasswordGeneratorScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -22,7 +21,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -36,7 +34,9 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButtonWi import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter @@ -55,18 +55,14 @@ fun MasterPasswordGeneratorScreen( viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val context = LocalContext.current - val resources = context.resources - val snackbarHostState = remember { - SnackbarHostState() - } + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack() MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout() is MasterPasswordGeneratorEvent.ShowSnackbar -> { snackbarHostState.showSnackbar( - message = event.text.toString(resources), + snackbarData = BitwardenSnackbarData(message = event.text), duration = SnackbarDuration.Short, ) } @@ -100,7 +96,7 @@ fun MasterPasswordGeneratorScreen( ) }, snackbarHost = { - BitwardenSnackbarHost(hostState = snackbarHostState) + BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { innerPadding -> Column( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButton.kt index 4bcbe02aa81..c490433ddd9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButton.kt @@ -2,10 +2,13 @@ package com.x8bit.bitwarden.ui.platform.components.button import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonColors import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -26,6 +29,7 @@ fun BitwardenOutlinedButton( onClick: () -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, + colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(), ) { OutlinedButton( onClick = onClick, @@ -36,13 +40,13 @@ fun BitwardenOutlinedButton( vertical = 10.dp, horizontal = 24.dp, ), - colors = bitwardenOutlinedButtonColors(), + colors = colors.materialButtonColors, border = BorderStroke( width = 1.dp, color = if (isEnabled) { - BitwardenTheme.colorScheme.outlineButton.border + colors.outlineBorderColor } else { - BitwardenTheme.colorScheme.outlineButton.borderDisabled + colors.outlinedDisabledBorderColor }, ), ) { @@ -53,6 +57,16 @@ fun BitwardenOutlinedButton( } } +/** + * Colors for a [BitwardenOutlinedButton]. + */ +@Immutable +data class BitwardenOutlinedButtonColors( + val materialButtonColors: ButtonColors, + val outlineBorderColor: Color, + val outlinedDisabledBorderColor: Color, +) + @Preview @Composable private fun BitwardenOutlinedButton_preview_isEnabled() { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButtonWithIcon.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButtonWithIcon.kt index b65856d7d8d..ec4813beab8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButtonWithIcon.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenOutlinedButtonWithIcon.kt @@ -33,6 +33,7 @@ fun BitwardenOutlinedButtonWithIcon( onClick: () -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, + colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(), ) { OutlinedButton( onClick = onClick, @@ -43,13 +44,13 @@ fun BitwardenOutlinedButtonWithIcon( vertical = 10.dp, horizontal = 24.dp, ), - colors = bitwardenOutlinedButtonColors(), + colors = colors.materialButtonColors, border = BorderStroke( width = 1.dp, color = if (isEnabled) { - BitwardenTheme.colorScheme.outlineButton.border + colors.outlineBorderColor } else { - BitwardenTheme.colorScheme.outlineButton.borderDisabled + colors.outlinedDisabledBorderColor }, ), ) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/color/BitwardenButtonColors.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/color/BitwardenButtonColors.kt index 71576fd79e7..6370ed8a7b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/color/BitwardenButtonColors.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/color/BitwardenButtonColors.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.components.button.color import androidx.compose.material3.ButtonColors import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButtonColors import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** @@ -42,12 +43,21 @@ fun bitwardenFilledTonalButtonColors(): ButtonColors = ButtonColors( * Provides a default set of Bitwarden-styled colors for an outlined button. */ @Composable -fun bitwardenOutlinedButtonColors(): ButtonColors = ButtonColors( - containerColor = Color.Transparent, - contentColor = BitwardenTheme.colorScheme.outlineButton.foreground, - disabledContainerColor = Color.Transparent, - disabledContentColor = BitwardenTheme.colorScheme.outlineButton.foregroundDisabled, -) +fun bitwardenOutlinedButtonColors( + contentColor: Color = BitwardenTheme.colorScheme.outlineButton.foreground, + outlineColor: Color = BitwardenTheme.colorScheme.outlineButton.border, + outlineColorDisabled: Color = BitwardenTheme.colorScheme.outlineButton.borderDisabled, +): BitwardenOutlinedButtonColors = + BitwardenOutlinedButtonColors( + materialButtonColors = ButtonColors( + containerColor = Color.Transparent, + contentColor = contentColor, + disabledContainerColor = Color.Transparent, + disabledContentColor = BitwardenTheme.colorScheme.outlineButton.foregroundDisabled, + ), + outlineBorderColor = outlineColor, + outlinedDisabledBorderColor = outlineColorDisabled, + ) /** * Provides a default set of Bitwarden-styled colors for an outlined error button. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt new file mode 100644 index 00000000000..827e8d2f900 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbar.kt @@ -0,0 +1,111 @@ +package com.x8bit.bitwarden.ui.platform.components.snackbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton +import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenOutlinedButtonColors +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * Custom snackbar for Bitwarden. + * Shows a message with an optional actions and title. + */ +@Composable +fun BitwardenSnackbar( + bitwardenSnackbarData: BitwardenSnackbarData, + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, + onActionClick: () -> Unit = {}, +) { + Box( + modifier = modifier.padding(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = BitwardenTheme.colorScheme.background.alert, + shape = BitwardenTheme.shapes.snackbar, + ) + .padding(16.dp), + ) { + Column { + bitwardenSnackbarData.messageHeader?.let { + Text( + text = it(), + color = BitwardenTheme.colorScheme.text.reversed, + style = BitwardenTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + } + Text( + text = bitwardenSnackbarData.message(), + color = BitwardenTheme.colorScheme.text.reversed, + style = BitwardenTheme.typography.bodyMedium, + ) + bitwardenSnackbarData.actionLabel?.let { + Spacer(Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = it(), + onClick = onActionClick, + colors = bitwardenOutlinedButtonColors( + contentColor = BitwardenTheme.colorScheme.text.reversed, + outlineColor = BitwardenTheme + .colorScheme + .outlineButton + .borderReversed, + ), + ) + } + } + if (bitwardenSnackbarData.withDismissAction) { + Spacer(Modifier.weight(1f)) + IconButton( + onClick = onDismiss, + content = { + Icon( + rememberVectorPainter(R.drawable.ic_close), + contentDescription = stringResource(R.string.close), + tint = BitwardenTheme.colorScheme.icon.reversed, + ) + }, + ) + } + } + } +} + +@Preview +@Composable +private fun BitwardenCustomSnackbar_preview() { + BitwardenTheme { + Surface { + BitwardenSnackbar( + BitwardenSnackbarData( + messageHeader = "Header".asText(), + message = "Message".asText(), + actionLabel = "Action".asText(), + withDismissAction = true, + ), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt index 5c0592d0000..ada958dd6e7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHost.kt @@ -1,35 +1,32 @@ package com.x8bit.bitwarden.ui.platform.components.snackbar -import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * A custom Bitwarden-themed snackbar. * - * @param hostState The state of this snackbar. - * @param modifier The [Modifier] to be applied to this radio button. + * @param bitwardenHostState The state of this snackbar. + * @param modifier The [Modifier] to be applied to the [SnackbarHost]. */ @Composable fun BitwardenSnackbarHost( - hostState: SnackbarHostState, + bitwardenHostState: BitwardenSnackbarHostState, modifier: Modifier = Modifier, ) { SnackbarHost( - hostState = hostState, + hostState = bitwardenHostState.snackbarHostState, modifier = modifier, - ) { - Snackbar( - snackbarData = it, - shape = BitwardenTheme.shapes.snackbar, - containerColor = BitwardenTheme.colorScheme.background.alert, - contentColor = BitwardenTheme.colorScheme.text.reversed, - actionColor = BitwardenTheme.colorScheme.background.alert, - actionContentColor = BitwardenTheme.colorScheme.icon.reversed, - dismissActionContentColor = BitwardenTheme.colorScheme.icon.reversed, - ) + ) { snackbarData -> + val message = snackbarData.visuals.message + val currentCustomSnackbarData = bitwardenHostState.currentSnackbarData + if (currentCustomSnackbarData?.key == message) { + BitwardenSnackbar( + bitwardenSnackbarData = currentCustomSnackbarData, + onDismiss = snackbarData::dismiss, + onActionClick = snackbarData::performAction, + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt new file mode 100644 index 00000000000..c4b18741b9b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt @@ -0,0 +1,71 @@ +package com.x8bit.bitwarden.ui.platform.components.snackbar + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.x8bit.bitwarden.ui.platform.base.util.Text + +/** + * A custom state holder for [BitwardenSnackbarData] and manging a snackbar host with the + * passed in [SnackbarHostState]. + */ +@Stable +class BitwardenSnackbarHostState( + val snackbarHostState: SnackbarHostState, +) { + /** + * The current snackbar data to be displayed. + */ + var currentSnackbarData: BitwardenSnackbarData? by mutableStateOf(null) + private set + + /** + * Shows a snackbar with the given [snackbarData]. Passes the [BitwardenSnackbarData.key] + * through the message parameter of the [SnackbarHostState.showSnackbar] method. This key + * can be used to identify the correct snackbar data to show in the host. + */ + suspend fun showSnackbar( + snackbarData: BitwardenSnackbarData, + duration: SnackbarDuration = SnackbarDuration.Short, + ): SnackbarResult { + currentSnackbarData = snackbarData + return snackbarHostState + .showSnackbar(message = snackbarData.key, duration = duration) + .also { currentSnackbarData = null } + } +} + +/** + * Models possible data to show in a custom bitwarden snackbar. + * @property message The text to show in the snackbar. + * @property messageHeader The optional title text to show. + * @property actionLabel The optional text to show in the action button. + * @property withDismissAction Whether to show the dismiss action. + * @property key The unique key for the [BitwardenSnackbarData]. + */ +@Immutable +data class BitwardenSnackbarData( + val message: Text, + val messageHeader: Text? = null, + val actionLabel: Text? = null, + val withDismissAction: Boolean = false, +) { + val key: String = this.hashCode().toString() +} + +/** + * Creates a [BitwardenSnackbarHostState] that is remembered across compositions. + */ +@Composable +fun rememberBitwardenSnackbarHostState( + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) = remember { + BitwardenSnackbarHostState(snackbarHostState) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index c1df494fbc7..043ddbdb650 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState @@ -24,7 +23,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -55,7 +53,9 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.segment.BitwardenSegmentedButton import com.x8bit.bitwarden.ui.platform.components.segment.SegmentedButtonState import com.x8bit.bitwarden.ui.platform.components.slider.BitwardenSlider +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation @@ -86,9 +86,7 @@ fun GeneratorScreen( intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val context = LocalContext.current - val resources = context.resources - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = rememberBitwardenSnackbarHostState() LivecycleEventEffect { _, event -> when (event) { @@ -106,7 +104,7 @@ fun GeneratorScreen( is GeneratorEvent.ShowSnackbar -> { snackbarHostState.showSnackbar( - message = event.message(resources).toString(), + snackbarData = BitwardenSnackbarData(message = event.message), duration = SnackbarDuration.Short, ) } @@ -199,7 +197,7 @@ fun GeneratorScreen( } }, snackbarHost = { - BitwardenSnackbarHost(hostState = snackbarHostState) + BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding ->