PM-13943 : PT1 Custom snackbar UI (#4135)

This commit is contained in:
Dave Severns 2024-10-23 10:44:59 -04:00 committed by GitHub
parent c5a266dfc0
commit a23fc319de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 243 additions and 45 deletions

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
@ -22,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField 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.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.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.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
@ -55,18 +55,14 @@ fun MasterPasswordGeneratorScreen(
viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(), viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val snackbarHostState = rememberBitwardenSnackbarHostState()
val resources = context.resources
val snackbarHostState = remember {
SnackbarHostState()
}
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack() MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack()
MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout() MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout()
is MasterPasswordGeneratorEvent.ShowSnackbar -> { is MasterPasswordGeneratorEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = event.text.toString(resources), snackbarData = BitwardenSnackbarData(message = event.text),
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short,
) )
} }
@ -100,7 +96,7 @@ fun MasterPasswordGeneratorScreen(
) )
}, },
snackbarHost = { snackbarHost = {
BitwardenSnackbarHost(hostState = snackbarHostState) BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
}, },
) { innerPadding -> ) { innerPadding ->
Column( Column(

View file

@ -2,10 +2,13 @@ package com.x8bit.bitwarden.ui.platform.components.button
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -26,6 +29,7 @@ fun BitwardenOutlinedButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isEnabled: Boolean = true, isEnabled: Boolean = true,
colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(),
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
@ -36,13 +40,13 @@ fun BitwardenOutlinedButton(
vertical = 10.dp, vertical = 10.dp,
horizontal = 24.dp, horizontal = 24.dp,
), ),
colors = bitwardenOutlinedButtonColors(), colors = colors.materialButtonColors,
border = BorderStroke( border = BorderStroke(
width = 1.dp, width = 1.dp,
color = if (isEnabled) { color = if (isEnabled) {
BitwardenTheme.colorScheme.outlineButton.border colors.outlineBorderColor
} else { } 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 @Preview
@Composable @Composable
private fun BitwardenOutlinedButton_preview_isEnabled() { private fun BitwardenOutlinedButton_preview_isEnabled() {

View file

@ -33,6 +33,7 @@ fun BitwardenOutlinedButtonWithIcon(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isEnabled: Boolean = true, isEnabled: Boolean = true,
colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(),
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
@ -43,13 +44,13 @@ fun BitwardenOutlinedButtonWithIcon(
vertical = 10.dp, vertical = 10.dp,
horizontal = 24.dp, horizontal = 24.dp,
), ),
colors = bitwardenOutlinedButtonColors(), colors = colors.materialButtonColors,
border = BorderStroke( border = BorderStroke(
width = 1.dp, width = 1.dp,
color = if (isEnabled) { color = if (isEnabled) {
BitwardenTheme.colorScheme.outlineButton.border colors.outlineBorderColor
} else { } else {
BitwardenTheme.colorScheme.outlineButton.borderDisabled colors.outlinedDisabledBorderColor
}, },
), ),
) { ) {

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.components.button.color
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButtonColors
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme 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. * Provides a default set of Bitwarden-styled colors for an outlined button.
*/ */
@Composable @Composable
fun bitwardenOutlinedButtonColors(): ButtonColors = ButtonColors( 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, containerColor = Color.Transparent,
contentColor = BitwardenTheme.colorScheme.outlineButton.foreground, contentColor = contentColor,
disabledContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent,
disabledContentColor = BitwardenTheme.colorScheme.outlineButton.foregroundDisabled, disabledContentColor = BitwardenTheme.colorScheme.outlineButton.foregroundDisabled,
) ),
outlineBorderColor = outlineColor,
outlinedDisabledBorderColor = outlineColorDisabled,
)
/** /**
* Provides a default set of Bitwarden-styled colors for an outlined error button. * Provides a default set of Bitwarden-styled colors for an outlined error button.

View file

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

View file

@ -1,35 +1,32 @@
package com.x8bit.bitwarden.ui.platform.components.snackbar package com.x8bit.bitwarden.ui.platform.components.snackbar
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/** /**
* A custom Bitwarden-themed snackbar. * A custom Bitwarden-themed snackbar.
* *
* @param hostState The state of this snackbar. * @param bitwardenHostState The state of this snackbar.
* @param modifier The [Modifier] to be applied to this radio button. * @param modifier The [Modifier] to be applied to the [SnackbarHost].
*/ */
@Composable @Composable
fun BitwardenSnackbarHost( fun BitwardenSnackbarHost(
hostState: SnackbarHostState, bitwardenHostState: BitwardenSnackbarHostState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
SnackbarHost( SnackbarHost(
hostState = hostState, hostState = bitwardenHostState.snackbarHostState,
modifier = modifier, modifier = modifier,
) { ) { snackbarData ->
Snackbar( val message = snackbarData.visuals.message
snackbarData = it, val currentCustomSnackbarData = bitwardenHostState.currentSnackbarData
shape = BitwardenTheme.shapes.snackbar, if (currentCustomSnackbarData?.key == message) {
containerColor = BitwardenTheme.colorScheme.background.alert, BitwardenSnackbar(
contentColor = BitwardenTheme.colorScheme.text.reversed, bitwardenSnackbarData = currentCustomSnackbarData,
actionColor = BitwardenTheme.colorScheme.background.alert, onDismiss = snackbarData::dismiss,
actionContentColor = BitwardenTheme.colorScheme.icon.reversed, onActionClick = snackbarData::performAction,
dismissActionContentColor = BitwardenTheme.colorScheme.icon.reversed,
) )
} }
}
} }

View file

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

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
@ -24,7 +23,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview 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.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.segment.SegmentedButtonState 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.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.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.stepper.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
@ -86,9 +86,7 @@ fun GeneratorScreen(
intentManager: IntentManager = LocalIntentManager.current, intentManager: IntentManager = LocalIntentManager.current,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val snackbarHostState = rememberBitwardenSnackbarHostState()
val resources = context.resources
val snackbarHostState = remember { SnackbarHostState() }
LivecycleEventEffect { _, event -> LivecycleEventEffect { _, event ->
when (event) { when (event) {
@ -106,7 +104,7 @@ fun GeneratorScreen(
is GeneratorEvent.ShowSnackbar -> { is GeneratorEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = event.message(resources).toString(), snackbarData = BitwardenSnackbarData(message = event.message),
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short,
) )
} }
@ -199,7 +197,7 @@ fun GeneratorScreen(
} }
}, },
snackbarHost = { snackbarHost = {
BitwardenSnackbarHost(hostState = snackbarHostState) BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
}, },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { innerPadding -> ) { innerPadding ->