PM-16622 PM-16623 and PM-16624 Add the first three coach marks to the generator tour (#4613)

This commit is contained in:
Dave Severns
2025-01-28 13:33:19 -05:00
committed by GitHub
parent 3c7262d2b3
commit a681402956
16 changed files with 414 additions and 114 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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].
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">Youll 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>

View File

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

View File

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

View File

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