mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 20:54:58 -05:00
PM-16622 PM-16623 and PM-16624 Add the first three coach marks to the generator tour (#4613)
This commit is contained in:
@@ -38,8 +38,6 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val ROUNDED_RECT_RADIUS = 8f
|
||||
|
||||
/**
|
||||
* A composable container that manages and displays coach mark highlights.
|
||||
*
|
||||
@@ -79,17 +77,17 @@ fun <T : Enum<T>> CoachMarkContainer(
|
||||
bottomRight = boundedRectangle.bottomRight,
|
||||
)
|
||||
Path().apply {
|
||||
when (currentHighlightShape) {
|
||||
CoachMarkHighlightShape.SQUARE -> addRoundRect(
|
||||
when (val shape = currentHighlightShape) {
|
||||
is CoachMarkHighlightShape.RoundedRectangle -> addRoundRect(
|
||||
RoundRect(
|
||||
rect = highlightArea,
|
||||
cornerRadius = CornerRadius(
|
||||
x = ROUNDED_RECT_RADIUS,
|
||||
x = shape.radius,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
CoachMarkHighlightShape.OVAL -> addOval(highlightArea)
|
||||
CoachMarkHighlightShape.Oval -> addOval(highlightArea)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +187,7 @@ private fun BitwardenCoachMarkContainer_preview() {
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
)
|
||||
},
|
||||
shape = CoachMarkHighlightShape.OVAL,
|
||||
shape = CoachMarkHighlightShape.Oval,
|
||||
) {
|
||||
BitwardenStandardIconButton(
|
||||
painter = rememberVectorPainter(R.drawable.ic_puzzle),
|
||||
@@ -203,6 +201,7 @@ private fun BitwardenCoachMarkContainer_preview() {
|
||||
key = Foo.Baz,
|
||||
title = "Foo",
|
||||
description = "Baz",
|
||||
shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f),
|
||||
leftAction = {
|
||||
BitwardenClickableText(
|
||||
label = "Back",
|
||||
|
||||
@@ -53,7 +53,7 @@ interface CoachMarkScope<T : Enum<T>> {
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
leftAction: (@Composable RowScope.() -> Unit)? = null,
|
||||
rightAction: (@Composable RowScope.() -> Unit)? = null,
|
||||
@@ -81,7 +81,7 @@ interface CoachMarkScope<T : Enum<T>> {
|
||||
title: Text,
|
||||
description: Text,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
leftAction: (@Composable RowScope.() -> Unit)? = null,
|
||||
rightAction: (@Composable RowScope.() -> Unit)? = null,
|
||||
@@ -112,7 +112,7 @@ interface CoachMarkScope<T : Enum<T>> {
|
||||
title: Text,
|
||||
description: Text,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
|
||||
items: List<R>,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
leftAction: (@Composable RowScope.() -> Unit)? = null,
|
||||
|
||||
@@ -38,7 +38,9 @@ open class CoachMarkState<T : Enum<T>>(
|
||||
val currentHighlight: State<T?> = mutableCurrentHighlight
|
||||
private val mutableCurrentHighlightBounds = mutableStateOf(Rect.Zero)
|
||||
val currentHighlightBounds: State<Rect> = mutableCurrentHighlightBounds
|
||||
private val mutableCurrentHighlightShape = mutableStateOf(CoachMarkHighlightShape.SQUARE)
|
||||
private val mutableCurrentHighlightShape = mutableStateOf<CoachMarkHighlightShape>(
|
||||
CoachMarkHighlightShape.RoundedRectangle(),
|
||||
)
|
||||
val currentHighlightShape: State<CoachMarkHighlightShape> = mutableCurrentHighlightShape
|
||||
|
||||
private val mutableIsVisible = mutableStateOf(isCoachMarkVisible)
|
||||
@@ -53,13 +55,13 @@ open class CoachMarkState<T : Enum<T>>(
|
||||
* Rect.Zero.
|
||||
* @param toolTipState The state of the tooltip associated with this highlight.
|
||||
* @param shape The shape of the highlight (e.g., square, oval). Defaults to
|
||||
* [CoachMarkHighlightShape.SQUARE].
|
||||
* [CoachMarkHighlightShape.RoundedRectangle].
|
||||
*/
|
||||
fun updateHighlight(
|
||||
key: T,
|
||||
bounds: Rect?,
|
||||
toolTipState: BitwardenToolTipState,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
|
||||
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
|
||||
) {
|
||||
highlights[key] = CoachMarkHighlightState(
|
||||
key = key,
|
||||
@@ -168,7 +170,7 @@ open class CoachMarkState<T : Enum<T>>(
|
||||
getCurrentHighlight()?.toolTipState?.cleanUp()
|
||||
mutableCurrentHighlight.value = null
|
||||
mutableCurrentHighlightBounds.value = Rect.Zero
|
||||
mutableCurrentHighlightShape.value = CoachMarkHighlightShape.SQUARE
|
||||
mutableCurrentHighlightShape.value = CoachMarkHighlightShape.RoundedRectangle()
|
||||
mutableIsVisible.value = false
|
||||
onComplete?.invoke()
|
||||
}
|
||||
@@ -184,7 +186,8 @@ open class CoachMarkState<T : Enum<T>>(
|
||||
|
||||
private fun updateCoachMarkStateInternal(highlight: CoachMarkHighlightState<T>?) {
|
||||
mutableIsVisible.value = highlight != null
|
||||
mutableCurrentHighlightShape.value = highlight?.shape ?: CoachMarkHighlightShape.SQUARE
|
||||
mutableCurrentHighlightShape.value =
|
||||
highlight?.shape ?: CoachMarkHighlightShape.RoundedRectangle()
|
||||
if (currentHighlightBounds.value != highlight?.highlightBounds) {
|
||||
mutableCurrentHighlightBounds.value = highlight?.highlightBounds ?: Rect.Zero
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.x8bit.bitwarden.ui.platform.components.coachmark.model
|
||||
|
||||
private const val ROUNDED_RECT_DEFAULT_RADIUS = 8f
|
||||
|
||||
/**
|
||||
* Defines the available shapes for a coach mark highlight.
|
||||
*/
|
||||
enum class CoachMarkHighlightShape {
|
||||
sealed class CoachMarkHighlightShape {
|
||||
/**
|
||||
* A square-shaped highlight.
|
||||
* A rounded rectangle shape which has a radius to round the corners by.
|
||||
*
|
||||
* @property radius the radius to use to round the corners of the rectangle shape.
|
||||
* Defaults to [ROUNDED_RECT_DEFAULT_RADIUS]
|
||||
*/
|
||||
SQUARE,
|
||||
data class RoundedRectangle(
|
||||
val radius: Float = ROUNDED_RECT_DEFAULT_RADIUS,
|
||||
) : CoachMarkHighlightShape()
|
||||
|
||||
/**
|
||||
* An oval-shaped highlight.
|
||||
*/
|
||||
OVAL,
|
||||
data object Oval : CoachMarkHighlightShape()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -39,6 +40,14 @@ fun BitwardenSegmentedButton(
|
||||
windowInsets: WindowInsets = WindowInsets.displayCutout
|
||||
.union(WindowInsets.navigationBars)
|
||||
.only(WindowInsetsSides.Horizontal),
|
||||
optionContent: @Composable SingleChoiceSegmentedButtonRowScope.(
|
||||
Int,
|
||||
SegmentedButtonState,
|
||||
) -> Unit = { _, optionState ->
|
||||
this.SegmentedButtonOptionContent(
|
||||
option = optionState,
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (options.isEmpty()) return
|
||||
Box(
|
||||
@@ -58,29 +67,39 @@ fun BitwardenSegmentedButton(
|
||||
space = 0.dp,
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
enabled = option.isEnabled,
|
||||
selected = option.isChecked,
|
||||
onClick = option.onClick,
|
||||
colors = bitwardenSegmentedButtonColors(),
|
||||
shape = BitwardenTheme.shapes.segmentedControl,
|
||||
border = BorderStroke(width = 0.dp, color = Color.Transparent),
|
||||
label = {
|
||||
Text(
|
||||
text = option.text,
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
// No icon required
|
||||
},
|
||||
modifier = Modifier.semantics { option.testTag?.let { testTag = it } },
|
||||
)
|
||||
optionContent(index, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content definition for each option in a [BitwardenSegmentedButton].
|
||||
*/
|
||||
@Composable
|
||||
fun SingleChoiceSegmentedButtonRowScope.SegmentedButtonOptionContent(
|
||||
option: SegmentedButtonState,
|
||||
) {
|
||||
SegmentedButton(
|
||||
enabled = option.isEnabled,
|
||||
selected = option.isChecked,
|
||||
onClick = option.onClick,
|
||||
colors = bitwardenSegmentedButtonColors(),
|
||||
shape = BitwardenTheme.shapes.segmentedControl,
|
||||
border = BorderStroke(width = 0.dp, color = Color.Transparent),
|
||||
label = {
|
||||
Text(
|
||||
text = option.text,
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
// No icon required
|
||||
},
|
||||
modifier = Modifier.semantics { option.testTag?.let { testTag = it } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state for an individual button in a [BitwardenSegmentedButton].
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -172,7 +173,7 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
onNavigateToSetupAutoFillScreen: () -> Unit,
|
||||
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
|
||||
) {
|
||||
var shouldDimNavBar by remember { mutableStateOf(false) }
|
||||
var shouldDimNavBar by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// This scaffold will host screens that contain top bars while not hosting one itself.
|
||||
// We need to ignore the all insets here and let the content screens handle it themselves.
|
||||
@@ -239,6 +240,9 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
)
|
||||
generatorGraph(
|
||||
onNavigateToPasswordHistory = { navigateToPasswordHistory() },
|
||||
onDimNavBarRequest = { shouldDim ->
|
||||
shouldDimNavBar = shouldDim
|
||||
},
|
||||
)
|
||||
settingsGraph(
|
||||
navController = navController,
|
||||
|
||||
@@ -12,6 +12,7 @@ const val GENERATOR_GRAPH_ROUTE: String = "generator_graph"
|
||||
*/
|
||||
fun NavGraphBuilder.generatorGraph(
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
onDimNavBarRequest: (Boolean) -> Unit,
|
||||
) {
|
||||
navigation(
|
||||
route = GENERATOR_GRAPH_ROUTE,
|
||||
@@ -19,6 +20,7 @@ fun NavGraphBuilder.generatorGraph(
|
||||
) {
|
||||
generatorDestination(
|
||||
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
onDimNavBarRequest = onDimNavBarRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,13 @@ data class GeneratorArgs(
|
||||
*/
|
||||
fun NavGraphBuilder.generatorDestination(
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
onDimNavBarRequest: (Boolean) -> Unit,
|
||||
) {
|
||||
composable(GENERATOR_ROUTE) {
|
||||
GeneratorScreen(
|
||||
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
onNavigateBack = {},
|
||||
onDimNavBarRequest = onDimNavBarRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +78,7 @@ fun NavGraphBuilder.generatorModalDestination(
|
||||
GeneratorScreen(
|
||||
onNavigateToPasswordHistory = {},
|
||||
onNavigateBack = onNavigateBack,
|
||||
onDimNavBarRequest = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -18,9 +20,12 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
@@ -46,6 +51,11 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconBu
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkActionText
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkContainer
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.rememberLazyListCoachMarkState
|
||||
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
@@ -56,6 +66,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.TooltipData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle
|
||||
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.SegmentedButtonOptionContent
|
||||
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
|
||||
@@ -86,10 +97,12 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberPasswordH
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberPlusAddressedEmailHandlers
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberRandomWordHandlers
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberUsernameTypeHandlers
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.ExploreGeneratorCoachMark
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
@@ -102,6 +115,7 @@ fun GeneratorScreen(
|
||||
viewModel: GeneratorViewModel = hiltViewModel(),
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onDimNavBarRequest: (Boolean) -> Unit,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
@@ -116,6 +130,15 @@ fun GeneratorScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val coachMarkState = rememberLazyListCoachMarkState(
|
||||
orderedList = ExploreGeneratorCoachMark.entries,
|
||||
lazyListState = lazyListState,
|
||||
)
|
||||
|
||||
LaunchedEffect(key1 = coachMarkState.isVisible.value) {
|
||||
onDimNavBarRequest(coachMarkState.isVisible.value)
|
||||
}
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
GeneratorEvent.NavigateToPasswordHistory -> onNavigateToPasswordHistory()
|
||||
@@ -134,9 +157,11 @@ fun GeneratorScreen(
|
||||
}
|
||||
|
||||
GeneratorEvent.NavigateBack -> onNavigateBack.invoke()
|
||||
GeneratorEvent.StartCoachMarkTour -> {
|
||||
coachMarkState.showCoachMark(ExploreGeneratorCoachMark.PASSWORD_MODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onRegenerateClick: () -> Unit = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.RegenerateClick) }
|
||||
}
|
||||
@@ -158,6 +183,25 @@ fun GeneratorScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val onShowNextCoachMark: () -> Unit = remember {
|
||||
{
|
||||
scope.launch { coachMarkState.showNextCoachMark() }
|
||||
}
|
||||
}
|
||||
|
||||
val onShowPreviousCoachMark: () -> Unit = remember {
|
||||
{
|
||||
scope.launch { coachMarkState.showPreviousCoachMark() }
|
||||
}
|
||||
}
|
||||
|
||||
val onDismissCoachMark: () -> Unit = remember {
|
||||
{
|
||||
scope.launch { lazyListState.animateScrollToItem(index = 0) }
|
||||
}
|
||||
}
|
||||
|
||||
val passwordHandlers = rememberPasswordHandlers(viewModel)
|
||||
val passphraseHandlers = rememberPassphraseHandlers(viewModel)
|
||||
val usernameTypeHandlers = rememberUsernameTypeHandlers(viewModel)
|
||||
@@ -167,60 +211,74 @@ fun GeneratorScreen(
|
||||
val randomWordHandlers = rememberRandomWordHandlers(viewModel)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
when (val generatorMode = state.generatorMode) {
|
||||
is GeneratorMode.Modal -> {
|
||||
ModalAppBar(
|
||||
generatorMode = generatorMode,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onCloseClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.CloseClick) }
|
||||
},
|
||||
onSelectClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.SelectClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
GeneratorMode.Default -> {
|
||||
DefaultAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onPasswordHistoryClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
utilityBar = {
|
||||
MainStateOptionsItem(
|
||||
selectedType = state.selectedType,
|
||||
passcodePolicyOverride = state.passcodePolicyOverride,
|
||||
possibleMainStates = state.typeOptions.toImmutableList(),
|
||||
onMainStateOptionClicked = onMainStateOptionClicked,
|
||||
modifier = Modifier
|
||||
.scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior),
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
|
||||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
CoachMarkContainer(
|
||||
state = coachMarkState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
ScrollContent(
|
||||
state = state,
|
||||
onRegenerateClick = onRegenerateClick,
|
||||
onCopyClick = onCopyClick,
|
||||
onUsernameSubStateOptionClicked = onUsernameOptionClicked,
|
||||
passwordHandlers = passwordHandlers,
|
||||
passphraseHandlers = passphraseHandlers,
|
||||
usernameTypeHandlers = usernameTypeHandlers,
|
||||
forwardedEmailAliasHandlers = forwardedEmailAliasHandlers,
|
||||
plusAddressedEmailHandlers = plusAddressedEmailHandlers,
|
||||
catchAllEmailHandlers = catchAllEmailHandlers,
|
||||
randomWordHandlers = randomWordHandlers,
|
||||
)
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
when (val generatorMode = state.generatorMode) {
|
||||
is GeneratorMode.Modal -> {
|
||||
ModalAppBar(
|
||||
generatorMode = generatorMode,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onCloseClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.CloseClick) }
|
||||
},
|
||||
onSelectClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.SelectClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
GeneratorMode.Default -> {
|
||||
DefaultAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onPasswordHistoryClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
utilityBar = {
|
||||
MainStateOptionsItem(
|
||||
selectedType = state.selectedType,
|
||||
passcodePolicyOverride = state.passcodePolicyOverride,
|
||||
possibleMainStates = state.typeOptions.toImmutableList(),
|
||||
onMainStateOptionClicked = onMainStateOptionClicked,
|
||||
onShowNextCoachMark = onShowNextCoachMark,
|
||||
onShowPreviousCoachMark = onShowPreviousCoachMark,
|
||||
onDismissCoachMark = onDismissCoachMark,
|
||||
modifier = Modifier
|
||||
.scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior),
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
|
||||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) {
|
||||
ScrollContent(
|
||||
state = state,
|
||||
onRegenerateClick = onRegenerateClick,
|
||||
onCopyClick = onCopyClick,
|
||||
onUsernameSubStateOptionClicked = onUsernameOptionClicked,
|
||||
passwordHandlers = passwordHandlers,
|
||||
passphraseHandlers = passphraseHandlers,
|
||||
usernameTypeHandlers = usernameTypeHandlers,
|
||||
forwardedEmailAliasHandlers = forwardedEmailAliasHandlers,
|
||||
plusAddressedEmailHandlers = plusAddressedEmailHandlers,
|
||||
catchAllEmailHandlers = catchAllEmailHandlers,
|
||||
randomWordHandlers = randomWordHandlers,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Remove dim nav bar effect when we leave this screen.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onDimNavBarRequest(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,12 +477,16 @@ private fun GeneratedStringItem(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength", "LongMethod")
|
||||
@Composable
|
||||
private fun MainStateOptionsItem(
|
||||
private fun CoachMarkScope<ExploreGeneratorCoachMark>.MainStateOptionsItem(
|
||||
selectedType: GeneratorState.MainType,
|
||||
passcodePolicyOverride: GeneratorState.PasscodePolicyOverride?,
|
||||
possibleMainStates: ImmutableList<GeneratorState.MainTypeOption>,
|
||||
onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit,
|
||||
onShowNextCoachMark: () -> Unit,
|
||||
onShowPreviousCoachMark: () -> Unit,
|
||||
onDismissCoachMark: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BitwardenSegmentedButton(
|
||||
@@ -460,7 +522,85 @@ private fun MainStateOptionsItem(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(tag = "GeneratorTypePicker"),
|
||||
)
|
||||
) { index, option ->
|
||||
when (index) {
|
||||
0 -> {
|
||||
CoachMarkHighlight(
|
||||
key = ExploreGeneratorCoachMark.PASSWORD_MODE,
|
||||
title = stringResource(R.string.coachmark_1_of_6),
|
||||
description = stringResource(
|
||||
R.string.use_the_generator_to_create_secure_passwords_passphrases_and_usernames,
|
||||
),
|
||||
onDismiss = onDismissCoachMark,
|
||||
rightAction = {
|
||||
CoachMarkActionText(
|
||||
actionLabel = stringResource(R.string.next),
|
||||
onActionClick = onShowNextCoachMark,
|
||||
)
|
||||
},
|
||||
shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f),
|
||||
) {
|
||||
SegmentedButtonOptionContent(option = option)
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
CoachMarkHighlight(
|
||||
key = ExploreGeneratorCoachMark.PASSPHRASE_MODE,
|
||||
title = stringResource(R.string.coachmark_2_of_6),
|
||||
description = stringResource(
|
||||
R.string.passphrases_are_strong_passwords_that_are_often_easier_to_remember_and_type_than_random_passwords,
|
||||
),
|
||||
onDismiss = onDismissCoachMark,
|
||||
rightAction = {
|
||||
CoachMarkActionText(
|
||||
actionLabel = stringResource(R.string.next),
|
||||
onActionClick = onShowNextCoachMark,
|
||||
)
|
||||
},
|
||||
leftAction = {
|
||||
CoachMarkActionText(
|
||||
actionLabel = stringResource(R.string.back),
|
||||
onActionClick = onShowPreviousCoachMark,
|
||||
)
|
||||
},
|
||||
shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f),
|
||||
) {
|
||||
SegmentedButtonOptionContent(option = option)
|
||||
}
|
||||
}
|
||||
|
||||
2 -> {
|
||||
CoachMarkHighlight(
|
||||
key = ExploreGeneratorCoachMark.USERNAME_MODE,
|
||||
title = stringResource(R.string.coachmark_3_of_6),
|
||||
description = stringResource(
|
||||
R.string.unique_usernames_add_an_extra_layer_of_security_and_can_help_prevent_hackers_from_finding_your_accounts,
|
||||
),
|
||||
onDismiss = onDismissCoachMark,
|
||||
rightAction = {
|
||||
CoachMarkActionText(
|
||||
actionLabel = stringResource(R.string.next),
|
||||
onActionClick = onShowNextCoachMark,
|
||||
)
|
||||
},
|
||||
leftAction = {
|
||||
CoachMarkActionText(
|
||||
actionLabel = stringResource(R.string.back),
|
||||
onActionClick = onShowPreviousCoachMark,
|
||||
)
|
||||
},
|
||||
shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f),
|
||||
) {
|
||||
SegmentedButtonOptionContent(option = option)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
SegmentedButtonOptionContent(option = option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion ScrollContent and Static Items
|
||||
@@ -1210,6 +1350,7 @@ private fun Generator_preview() {
|
||||
GeneratorScreen(
|
||||
onNavigateToPasswordHistory = {},
|
||||
onNavigateBack = {},
|
||||
onDimNavBarRequest = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class GeneratorViewModel @Inject constructor(
|
||||
|
||||
private fun handleStartExploreGeneratorTour() {
|
||||
coachMarkTourCompleted()
|
||||
// TODO: PM-16622 send show coach mark event.
|
||||
sendEvent(GeneratorEvent.StartCoachMarkTour)
|
||||
}
|
||||
|
||||
private fun coachMarkTourCompleted() {
|
||||
@@ -2647,6 +2647,11 @@ sealed class GeneratorEvent {
|
||||
data class ShowSnackbar(
|
||||
val message: Text,
|
||||
) : GeneratorEvent()
|
||||
|
||||
/**
|
||||
* Triggers the start of showing the coach mark tour.
|
||||
*/
|
||||
data object StartCoachMarkTour : GeneratorEvent()
|
||||
}
|
||||
|
||||
@Suppress("ComplexCondition", "MaxLineLength")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.model
|
||||
|
||||
/**
|
||||
* Enumerated values representing the keys for each coach mark to show on the GeneratorScreen.
|
||||
*/
|
||||
enum class ExploreGeneratorCoachMark {
|
||||
PASSWORD_MODE,
|
||||
PASSPHRASE_MODE,
|
||||
USERNAME_MODE,
|
||||
PASSWORD_OPTIONS,
|
||||
GENERATE_BUTTON,
|
||||
COPY_PASSWORD_BUTTON,
|
||||
}
|
||||
@@ -529,7 +529,7 @@ private fun CoachMarkScope<AddEditItemCoachMark>.PasswordRow(
|
||||
description = stringResource(
|
||||
R.string.use_this_button_to_generate_a_new_unique_password,
|
||||
),
|
||||
shape = CoachMarkHighlightShape.OVAL,
|
||||
shape = CoachMarkHighlightShape.Oval,
|
||||
onDismiss = onCoachMarkDismissed,
|
||||
rightAction = {
|
||||
CoachMarkActionText(
|
||||
|
||||
@@ -1117,10 +1117,10 @@ Do you want to switch to this account?</string>
|
||||
<string name="login_credentials">Login Credentials</string>
|
||||
<string name="autofill_options">Autofill Options</string>
|
||||
<string name="use_this_button_to_generate_a_new_unique_password">Use this button to generate a new unique password.</string>
|
||||
<string name="coachmark_1_of_3">1 of 3</string>
|
||||
<string name="coachmark_2_of_3">2 of 3</string>
|
||||
<string name="coachmark_1_of_3">1 OF 3</string>
|
||||
<string name="coachmark_2_of_3">2 OF 3</string>
|
||||
<string name="you_ll_only_need_to_set_up_authenticator_key">You’ll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in.</string>
|
||||
<string name="coachmark_3_of_3">3 of 3</string>
|
||||
<string name="coachmark_3_of_3">3 OF 3</string>
|
||||
<string name="you_must_add_a_web_address_to_use_autofill_to_access_this_account">You must add a web address to use autofill to access this account.</string>
|
||||
<string name="mutual_tls">Mutual TLS</string>
|
||||
<string name="single_tap_passkey_creation">Single tap passkey creation</string>
|
||||
@@ -1132,4 +1132,10 @@ Do you want to switch to this account?</string>
|
||||
<string name="import_client_certificate">Import client certificate</string>
|
||||
<string name="enter_the_client_certificate_password_and_alias">Enter the client certificate password and the desired alias for this certificate.</string>
|
||||
<string name="alias">Alias</string>
|
||||
<string name="use_the_generator_to_create_secure_passwords_passphrases_and_usernames">"Use the generator to create secure passwords, passphrases and usernames. "</string>
|
||||
<string name="passphrases_are_strong_passwords_that_are_often_easier_to_remember_and_type_than_random_passwords">Passphrases are strong passwords that are often easier to remember and type than random passwords. They are helpful for logging into accounts where AutoFill is not available, like a streaming service on your TV.</string>
|
||||
<string name="unique_usernames_add_an_extra_layer_of_security_and_can_help_prevent_hackers_from_finding_your_accounts">Unique usernames add an extra layer of security and can help prevent hackers from finding your accounts.</string>
|
||||
<string name="coachmark_1_of_6">1 OF 6</string>
|
||||
<string name="coachmark_2_of_6">2 OF 6</string>
|
||||
<string name="coachmark_3_of_6">3 OF 6</string>
|
||||
</resources>
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
|
||||
import com.x8bit.bitwarden.ui.util.isCoachMarkToolTip
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
@@ -47,6 +48,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@Suppress("LargeClass")
|
||||
class GeneratorScreenTest : BaseComposeTest() {
|
||||
private var onNavigateToPasswordHistoryScreenCalled = false
|
||||
private var onDimNavBarRequest: Boolean? = null
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
@@ -66,6 +68,7 @@ class GeneratorScreenTest : BaseComposeTest() {
|
||||
viewModel = viewModel,
|
||||
onNavigateToPasswordHistory = { onNavigateToPasswordHistoryScreenCalled = true },
|
||||
onNavigateBack = {},
|
||||
onDimNavBarRequest = { onDimNavBarRequest = it },
|
||||
intentManager = intentManager,
|
||||
)
|
||||
}
|
||||
@@ -1606,6 +1609,97 @@ class GeneratorScreenTest : BaseComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when StartCoachMarkTour event is received the first coach mark is shown and onDimNavBarRequest sends value of true `() {
|
||||
mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertIsDisplayed()
|
||||
|
||||
assertTrue(onDimNavBarRequest == true)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when a coach mark close button is clicked no coach mark should be showing and onDimNavBarRequest sends the value of false`() {
|
||||
mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNode(
|
||||
hasContentDescription("Close") and
|
||||
hasAnyAncestor(isCoachMarkToolTip),
|
||||
)
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNode(isCoachMarkToolTip)
|
||||
.assertDoesNotExist()
|
||||
|
||||
assertTrue(onDimNavBarRequest == false)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when a coach mark next button is clicked should progress to the next coach mark`() {
|
||||
mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 OF 6")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when a coach mark back button is clicked should return to previous coach mark`() {
|
||||
mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 OF 6")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 OF 6")
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 OF 6")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
//endregion Random Word Tests
|
||||
|
||||
private fun updateState(state: GeneratorState) {
|
||||
|
||||
@@ -2227,9 +2227,13 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `StartExploreGeneratorTour action calls first time action manager hasSeenGeneratorCoachMarkTour called and show coach mark event sent`() {
|
||||
fun `StartExploreGeneratorTour action calls first time action manager markCoachMarkTourCompleted called and show coach mark event sent`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour)
|
||||
assertEquals(GeneratorEvent.StartCoachMarkTour, awaitItem())
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR)
|
||||
}
|
||||
|
||||
@@ -3458,7 +3458,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN
|
||||
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -3467,18 +3467,18 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN
|
||||
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsNotDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 of 3")
|
||||
.onNodeWithText("2 OF 3")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
@@ -3486,11 +3486,11 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 of 3")
|
||||
.onNodeWithText("2 OF 3")
|
||||
.assertIsNotDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -3499,14 +3499,14 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN
|
||||
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 of 3")
|
||||
.onNodeWithText("2 OF 3")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
@@ -3526,18 +3526,18 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN
|
||||
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("1 of 3")
|
||||
.onNodeWithText("1 OF 3")
|
||||
.assertIsNotDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 of 3")
|
||||
.onNodeWithText("2 OF 3")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
@@ -3545,11 +3545,11 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("2 of 3")
|
||||
.onNodeWithText("2 OF 3")
|
||||
.assertIsNotDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("3 of 3")
|
||||
.onNodeWithText("3 OF 3")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
|
||||
Reference in New Issue
Block a user