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.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(

View file

@ -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() {

View file

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

View file

@ -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.

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

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.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 ->