PM-16631 Applying CoachMarkContainer to the AddLoginItem content. (#4571)

This commit is contained in:
Dave Severns
2025-01-21 11:31:45 -05:00
committed by GitHub
parent 08e51fde98
commit 2b94e01c56
19 changed files with 1753 additions and 148 deletions

View File

@@ -23,12 +23,22 @@ import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -156,13 +166,55 @@ fun Modifier.tabNavigation(): Modifier {
*/
@OmitFromCoverage
@Stable
@Composable
fun Modifier.standardHorizontalMargin(
portrait: Dp = 16.dp,
landscape: Dp = 48.dp,
): Modifier {
val config = LocalConfiguration.current
return this.padding(horizontal = if (config.isPortrait) portrait else landscape)
): Modifier =
this then StandardHorizontalMarginElement(portrait = portrait, landscape = landscape)
private data class StandardHorizontalMarginElement(
private val portrait: Dp,
private val landscape: Dp,
) : ModifierNodeElement<StandardHorizontalMarginElement.StandardHorizontalMarginConsumerNode>() {
override fun create(): StandardHorizontalMarginConsumerNode =
StandardHorizontalMarginConsumerNode(
portrait = portrait,
landscape = landscape,
)
override fun update(node: StandardHorizontalMarginConsumerNode) {
node.portrait = portrait
node.landscape = landscape
}
class StandardHorizontalMarginConsumerNode(
var portrait: Dp,
var landscape: Dp,
) : Modifier.Node(),
LayoutModifierNode,
CompositionLocalConsumerModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val currentConfig = currentValueOf(LocalConfiguration)
val paddingPx = (if (currentConfig.isPortrait) portrait else landscape).roundToPx()
// Account for the padding on each side.
val horizontalPx = paddingPx * 2
// Measure the placeable within the horizontal space accounting for the padding Px.
val placeable = measurable.measure(
constraints = constraints.offset(
horizontal = -horizontalPx,
vertical = 0,
),
)
// The width of the placeable plus the total padding, used to create the layout.
val width = constraints.constrainWidth(width = placeable.width + horizontalPx)
return layout(width = width, height = placeable.height) {
placeable.place(x = paddingPx, y = 0)
}
}
}
}
/**

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Clickable text used for the standard action UI for a Coach Mark which applies
* correct text style by default.
*/
@Composable
fun CoachMarkActionText(
actionLabel: String,
onActionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenClickableText(
label = actionLabel,
onClick = onActionClick,
style = BitwardenTheme.typography.labelLarge,
modifier = modifier,
)
}

View File

@@ -0,0 +1,246 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.input.pointer.pointerInput
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.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
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.
*
* This composable provides a full-screen overlay that can highlight specific
* areas of the UI and display tooltips to guide the user through a sequence
* of steps or features.
*
* @param T The type of the enum used to represent the unique keys for each coach mark highlight.
* @param state The [CoachMarkState] that manages the sequence and state of the coach marks.
* @param modifier The modifier to be applied to the container.
* @param content The composable content that defines the coach mark highlights within the
* [CoachMarkScope].
*/
@Composable
@Suppress("LongMethod")
fun <T : Enum<T>> CoachMarkContainer(
state: CoachMarkState<T>,
modifier: Modifier = Modifier,
content: @Composable CoachMarkScope<T>.() -> Unit,
) {
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.then(modifier),
) {
CoachMarkScopeInstance(coachMarkState = state).content()
val boundedRectangle by state.currentHighlightBounds
val isVisible by state.isVisible
val currentHighlightShape by state.currentHighlightShape
val highlightPath = remember(boundedRectangle, currentHighlightShape) {
if (boundedRectangle == Rect.Zero) {
return@remember Path()
}
val highlightArea = Rect(
topLeft = boundedRectangle.topLeft,
bottomRight = boundedRectangle.bottomRight,
)
Path().apply {
when (currentHighlightShape) {
CoachMarkHighlightShape.SQUARE -> addRoundRect(
RoundRect(
rect = highlightArea,
cornerRadius = CornerRadius(
x = ROUNDED_RECT_RADIUS,
),
),
)
CoachMarkHighlightShape.OVAL -> addOval(highlightArea)
}
}
}
if (boundedRectangle != Rect.Zero && isVisible) {
val backgroundColor = BitwardenTheme.colorScheme.background.scrim
Box(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
scope.launch {
state.showToolTipForCurrentCoachMark()
}
},
)
}
.fillMaxSize()
.drawBehind {
clipPath(
path = highlightPath,
clipOp = ClipOp.Difference,
block = {
drawRect(
color = backgroundColor,
)
},
)
},
)
}
// Once the bounds and shape update show the tooltip for the active coach mark.
LaunchedEffect(state.currentHighlightBounds.value, state.currentHighlightShape.value) {
if (state.currentHighlightBounds.value != Rect.Zero) {
state.showToolTipForCurrentCoachMark()
}
}
// On the initial composition of the screen check to see if the coach mark was visible and
// then show the associated coach mark.
LaunchedEffect(Unit) {
if (state.isVisible.value) {
state.currentHighlight.value?.let {
state.showCoachMark(it)
}
}
}
}
}
@Preview
@Composable
@Suppress("LongMethod")
private fun BitwardenCoachMarkContainer_preview() {
BitwardenTheme {
val state = rememberCoachMarkState(Foo.entries)
val scope = rememberCoroutineScope()
CoachMarkContainer(
state = state,
) {
Column(
modifier = Modifier
.background(BitwardenTheme.colorScheme.background.primary)
.padding(top = 100.dp)
.padding(horizontal = 16.dp)
.fillMaxSize(),
) {
BitwardenClickableText(
label = "Start Coach Mark Flow",
onClick = {
scope.launch {
state.showCoachMark(Foo.Bar)
}
},
style = BitwardenTheme.typography.labelLarge,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
CoachMarkHighlight(
key = Foo.Bar,
title = "1 of 3",
description = "Use this button to generate a new unique password.",
rightAction = {
BitwardenClickableText(
label = "Next",
onClick = {
scope.launch {
state.showNextCoachMark()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
shape = CoachMarkHighlightShape.OVAL,
) {
BitwardenStandardIconButton(
painter = rememberVectorPainter(R.drawable.ic_puzzle),
contentDescription = stringResource(R.string.close),
onClick = {},
)
}
}
Spacer(Modifier.height(24.dp))
CoachMarkHighlight(
key = Foo.Baz,
title = "Foo",
description = "Baz",
leftAction = {
BitwardenClickableText(
label = "Back",
onClick = {
scope.launch {
state.showPreviousCoachMark()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
rightAction = {
BitwardenClickableText(
label = "Done",
onClick = {
scope.launch {
state.coachingComplete()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
) {
Text(text = "Foo Baz")
}
Spacer(Modifier.size(100.dp))
}
}
}
}
/**
* Example enum for demonstration purposes.
*/
private enum class Foo {
Bar,
Baz,
}

View File

@@ -0,0 +1,126 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
/**
* Defines the scope for creating coach mark highlights within a user interface.
*
* This interface provides a way to define and display a highlight that guides the user's
* attention to a specific part of the UI, often accompanied by a tooltip with
* explanatory text and actions.
*
* @param T The type of the enum used to represent the unique keys for each coach mark highlight.
*/
interface CoachMarkScope<T : Enum<T>> {
/**
* Creates a highlight for a specific coach mark.
*
* This function defines a region of the UI to be highlighted, along with an
* associated tooltip that can display a title, description, and actions.
*
* @param key The unique key identifying this highlight. This key is used to
* manage the state and order of the coach mark sequence.
* @param title The title of the coach mark, displayed in the tooltip.
* @param description The description of the coach mark, providing more context
* to the user. Displayed in the tooltip.
* @param shape The shape of the highlight. Defaults to [CoachMarkHighlightShape.SQUARE].
* Use [CoachMarkHighlightShape.OVAL] for a circular highlight.
* @param onDismiss An optional callback that is invoked when the coach mark is dismissed
* (e.g., by clicking the close button). If provided, this function
* will be executed after the coach mark is dismissed. If not provided,
* no action is taken on dismissal.
* @param leftAction An optional composable to be displayed on the left side of the
* action row in the tooltip. This can be used to provide
* additional actions or controls.
* @param rightAction An optional composable to be displayed on the right side of the
* action row in the tooltip. This can be used to provide
* primary actions or navigation.
* @param anchorContent The composable content to be highlighted. This is the UI element
* that will be visually emphasized by the coach mark.
*/
@Composable
fun CoachMarkHighlight(
key: T,
title: String,
description: String,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
anchorContent: @Composable () -> Unit,
)
/**
* Creates a [CoachMarkScope.CoachMarkHighlight] in the context of a [LazyListScope],
* automatically assigns the value of [key] as the [LazyListScope.item]'s `key` value.
* This is used to be able to find the item to apply the coach mark to in the LazyList.
* Analogous with [LazyListScope.item] in the context of adding a coach mark around an entire
* item.
*
* @param key The key used for the CoachMark data as well as the `item.key` to find within
* the `LazyList`.
*
* @see [CoachMarkScope.CoachMarkHighlight]
*
* Note: If you are only intending "highlight" part of an `item` you will want to give that
* item the same `key` as the [key] for the coach mark.
*/
@Suppress("LongParameterList")
fun LazyListScope.coachMarkHighlightItem(
key: T,
title: Text,
description: Text,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
anchorContent: @Composable () -> Unit,
)
/**
* Allows for wrapping an entire list of [items] in a single Coach Mark Highlight. The
* anchor for the tooltip and the scrolling target will be the start/top of the content.
*
* @param items Typed list of items to display in the [LazyListScope.items] block.
* @param leadingStaticContent Optional static content to slot in a [LazyListScope.item]
* ahead of the list of items.
* @param leadingContentIsTopCard To denote that the leading content is the "top" part of a
* card creating using [CardStyle].
* @param trailingStaticContent Optional static content to slot in a [LazyListScope.item]
* after the list of items.
* @param trailingContentIsBottomCard To denote that the trailing content is the "top" part of
* a card creating using [CardStyle].
* @param itemContent The content to draw for each [R] in [items] and the necessary
* [CardStyle] based on its position and other factors.
*
* @see [CoachMarkScope.CoachMarkHighlight]
*/
@Suppress("LongParameterList")
fun <R> LazyListScope.coachMarkHighlightItems(
key: T,
title: Text,
description: Text,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
items: List<R>,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
leadingStaticContent: (@Composable BoxScope.() -> Unit)? = null,
leadingContentIsTopCard: Boolean = false,
trailingStaticContent: (@Composable BoxScope.() -> Unit)? = null,
trailingContentIsBottomCard: Boolean = false,
itemContent: @Composable (R, CardStyle) -> Unit,
)
}

View File

@@ -0,0 +1,307 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TooltipState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTip
import okhttp3.internal.toImmutableList
import org.jetbrains.annotations.VisibleForTesting
/**
* Creates an instance of [CoachMarkScope] for a given [CoachMarkState].
*/
@OptIn(ExperimentalMaterial3Api::class)
class CoachMarkScopeInstance<T : Enum<T>>(
private val coachMarkState: CoachMarkState<T>,
) : CoachMarkScope<T> {
@Composable
override fun CoachMarkHighlight(
key: T,
title: String,
description: String,
modifier: Modifier,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit,
) {
val toolTipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = true,
)
CoachMarkHighlightInternal(
key = key,
title = title,
description = description,
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
toolTipState = toolTipState,
modifier = modifier.onGloballyPositioned {
coachMarkState.updateHighlight(
key = key,
bounds = it.boundsInRoot(),
toolTipState = toolTipState,
shape = shape,
)
},
anchorContent = anchorContent,
)
}
override fun LazyListScope.coachMarkHighlightItem(
key: T,
title: Text,
description: Text,
modifier: Modifier,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit,
) {
item(key = key) {
this@CoachMarkScopeInstance.CoachMarkHighlight(
key = key,
title = title(),
description = description(),
modifier = modifier,
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
anchorContent = anchorContent,
)
}
}
override fun <R> LazyListScope.coachMarkHighlightItems(
key: T,
title: Text,
description: Text,
modifier: Modifier,
shape: CoachMarkHighlightShape,
items: List<R>,
onDismiss: (() -> Unit)?,
leftAction: @Composable (RowScope.() -> Unit)?,
rightAction: @Composable (RowScope.() -> Unit)?,
leadingStaticContent: @Composable (BoxScope.() -> Unit)?,
leadingContentIsTopCard: Boolean,
trailingStaticContent: @Composable (BoxScope.() -> Unit)?,
trailingContentIsBottomCard: Boolean,
itemContent: @Composable (item: R, cardStyle: CardStyle) -> Unit,
) {
val hasLeadingContent = (leadingStaticContent != null)
var topCardAlreadyExists = hasLeadingContent && leadingContentIsTopCard
val bottomCardAlreadyExists = (trailingStaticContent != null) && trailingContentIsBottomCard
val itemsAdjusted = items
.drop(if (hasLeadingContent) 0 else 1)
.toImmutableList()
item(key = key) {
this@CoachMarkScopeInstance.CoachMarkHighlightInternal(
key = key,
title = title(),
description = description(),
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
) {
Box(
modifier = modifier.calculateBoundsAndAddForKey(key = key, isFirstItem = true),
) {
leadingStaticContent?.invoke(this) ?: run {
if (items.isNotEmpty()) {
itemContent(
items.first(),
items.toCoachMarkListItemCardStyle(
index = 0,
topCardAlreadyExists = false,
bottomCardAlreadyExists = bottomCardAlreadyExists,
),
)
topCardAlreadyExists = true
}
}
}
}
}
itemsIndexed(
itemsAdjusted,
) { index, item ->
Box(
modifier = modifier.calculateBoundsAndAddForKey(key),
) {
val cardStyle = itemsAdjusted.toCoachMarkListItemCardStyle(
index = index,
topCardAlreadyExists = topCardAlreadyExists,
bottomCardAlreadyExists = bottomCardAlreadyExists,
)
itemContent(item, cardStyle)
}
}
trailingStaticContent?.let {
item {
Box(
modifier = modifier.calculateBoundsAndAddForKey(key),
content = it,
)
}
}
}
@Composable
private fun CoachMarkHighlightInternal(
key: T,
title: String,
description: String,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
modifier: Modifier = Modifier,
toolTipState: TooltipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = true,
),
anchorContent: @Composable () -> Unit,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberRichTooltipPositionProvider(
spacingBetweenTooltipAndAnchor = 12.dp,
),
tooltip = {
BitwardenToolTip(
title = title,
description = description,
onDismiss = {
coachMarkState.coachingComplete()
onDismiss?.invoke()
},
leftAction = leftAction,
rightAction = rightAction,
modifier = Modifier
.padding(horizontal = 4.dp)
.semantics { isCoachMarkToolTip = true },
)
},
enableUserInput = false,
focusable = false,
state = toolTipState,
modifier = modifier,
content = anchorContent,
)
LaunchedEffect(Unit) {
coachMarkState.updateHighlight(
key = key,
bounds = null,
toolTipState = toolTipState,
shape = shape,
)
}
}
@Composable
private fun Modifier.calculateBoundsAndAddForKey(
key: T,
isFirstItem: Boolean = false,
): Modifier {
var bounds: Rect? by remember {
mutableStateOf(null)
}
LaunchedEffect(bounds) {
bounds?.let {
coachMarkState.addToExistingBounds(
key = key,
isFirstItem = isFirstItem,
additionalBounds = it,
)
}
}
return this.onGloballyPositioned {
bounds = it.boundsInRoot()
}
}
}
/**
* Returns the appropriate [CardStyle] based on the current [index] in the list being used
* for a coachMarkHighlightItems list.
*/
private fun <T> Collection<T>.toCoachMarkListItemCardStyle(
index: Int,
topCardAlreadyExists: Boolean,
bottomCardAlreadyExists: Boolean,
hasDivider: Boolean = true,
dividerPadding: Dp = 16.dp,
): CardStyle = when {
topCardAlreadyExists && bottomCardAlreadyExists -> {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
topCardAlreadyExists && !bottomCardAlreadyExists -> {
if (this.size == 1) {
CardStyle.Bottom
} else if (index == this.size - 1) {
CardStyle.Bottom
} else {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
}
!topCardAlreadyExists && bottomCardAlreadyExists -> {
if (this.size == 1) {
CardStyle.Top(hasDivider = hasDivider, dividerPadding = dividerPadding)
} else if (index == 0) {
CardStyle.Top(hasDivider = hasDivider, dividerPadding = dividerPadding)
} else {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
}
else -> this.toListItemCardStyle(
index = index,
hasDivider = hasDivider,
dividerPadding = dividerPadding,
)
}
/**
* SemanticPropertyKey used for Unit tests where checking if any displayed CoachMarkToolTips
*/
@VisibleForTesting
val IsCoachMarkToolTipKey = SemanticsPropertyKey<Boolean>("IsCoachMarkToolTip")
private var SemanticsPropertyReceiver.isCoachMarkToolTip by IsCoachMarkToolTipKey

View File

@@ -0,0 +1,265 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Rect
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightState
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.math.min
/**
* Manages the state of a coach mark sequence, guiding users through a series of highlights.
*
* This class handles the ordered list of highlights, the currently active highlight,
* and the overall visibility of the coach mark overlay.
*
* @param T The type of the enum used to represent the coach mark keys.
* @property orderedList The ordered list of coach mark keys that define the sequence.
* @param initialCoachMarkHighlight The initial coach mark to be highlighted, or null if
* none should be highlighted at start.
* @param isCoachMarkVisible is any coach mark currently visible.
*/
@OptIn(ExperimentalMaterial3Api::class)
open class CoachMarkState<T : Enum<T>>(
val orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
isCoachMarkVisible: Boolean = false,
) {
private val highlights: MutableMap<T, CoachMarkHighlightState<T>?> = ConcurrentHashMap()
private val mutableCurrentHighlight = mutableStateOf(initialCoachMarkHighlight)
val currentHighlight: State<T?> = mutableCurrentHighlight
private val mutableCurrentHighlightBounds = mutableStateOf(Rect.Zero)
val currentHighlightBounds: State<Rect> = mutableCurrentHighlightBounds
private val mutableCurrentHighlightShape = mutableStateOf(CoachMarkHighlightShape.SQUARE)
val currentHighlightShape: State<CoachMarkHighlightShape> = mutableCurrentHighlightShape
private val mutableIsVisible = mutableStateOf(isCoachMarkVisible)
val isVisible: State<Boolean> = mutableIsVisible
/**
* Updates the highlight information for a given key. If the key matches the current shown
* [key] then also update the public state for the highlight bounds and shape.
*
* @param key The key of the highlight to update.
* @param bounds The rectangular bounds of the area to highlight. If null, defaults to
* 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].
*/
fun updateHighlight(
key: T,
bounds: Rect?,
toolTipState: TooltipState,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
) {
highlights[key] = CoachMarkHighlightState(
key = key,
highlightBounds = bounds,
toolTipState = toolTipState,
shape = shape,
)
.also {
if (key == currentHighlight.value) {
updateCoachMarkStateInternal(it)
}
}
}
/**
* For the provided [key] add a new rectangle to any existing bounds unless it is
* the first item then it is used as the "starting" rectangle.
*
* @param key the [CoachMarkHighlightState] to modify.
* @param isFirstItem if this new calculation is coming from the "first" or base item.
* @param additionalBounds the rectangle to add to the existing bounds.
*/
fun addToExistingBounds(key: T, isFirstItem: Boolean, additionalBounds: Rect) {
highlights[key]?.let {
val newRect = it.highlightBounds
?.union(additionalBounds)
.takeIf { !isFirstItem }
?: additionalBounds
highlights[key] = it.copy(highlightBounds = newRect)
if (key == currentHighlight.value) {
updateCoachMarkStateInternal(getCurrentHighlight())
}
}
}
/**
* Show the the tooltip for the currently shown tooltip.
*/
suspend fun showToolTipForCurrentCoachMark() {
val currentCoachMark = getCurrentHighlight()
currentCoachMark?.toolTipState?.show()
}
/**
* Indicates that the coach mark associated with the provided key should be shown and
* starts that process of updating the state.
*
* @param coachMarkToShow The key of the coach mark to show.
*/
open suspend fun showCoachMark(coachMarkToShow: T) {
// Clean up the previous tooltip if one is showing.
if (currentHighlight.value != coachMarkToShow && isVisible.value) {
getCurrentHighlight()?.toolTipState?.cleanUp()
}
mutableCurrentHighlight.value = coachMarkToShow
val highlightToShow = getCurrentHighlight()
updateCoachMarkStateInternal(highlightToShow)
}
/**
* Shows the next highlight in the sequence.
* If there is no previous highlight, it will show the first highlight.
* If the previous highlight is the last in the list, nothing will happen.
*/
suspend fun showNextCoachMark() {
val previousHighlight = getCurrentHighlight()
previousHighlight?.toolTipState?.cleanUp()
val index = orderedList.indexOf(previousHighlight?.key)
// We return early here if the the previous highlight does exist but is somehow not
// present in the list. If the previous highlight is null we resolve that the next
// coach mark to show is the first item in the orderedList.
if (index < 0 && previousHighlight != null) return
mutableCurrentHighlight.value = orderedList.getOrNull(index + 1)
mutableCurrentHighlight.value?.let {
showCoachMark(it)
}
}
/**
* Shows the previous coach mark in the sequence.
* If the current highlighted coach mark is the first in the list, the coach mark will
* be hidden.
*/
suspend fun showPreviousCoachMark() {
val currentHighlight = getCurrentHighlight() ?: return
currentHighlight.toolTipState.cleanUp()
val index = orderedList.indexOf(currentHighlight.key)
if (index == 0) {
mutableCurrentHighlight.value = null
mutableIsVisible.value = false
return
}
mutableCurrentHighlight.value = orderedList.getOrNull(index - 1)
mutableCurrentHighlight.value?.let {
showCoachMark(it)
}
}
/**
* Completes the coaching sequence, clearing all highlights and resetting the state.
*
* @param onComplete An optional callback to invoke once all the other clean up logic has
* taken place.
*/
fun coachingComplete(onComplete: (() -> Unit)? = null) {
getCurrentHighlight()?.toolTipState?.cleanUp()
mutableCurrentHighlight.value = null
mutableCurrentHighlightBounds.value = Rect.Zero
mutableCurrentHighlightShape.value = CoachMarkHighlightShape.SQUARE
mutableIsVisible.value = false
onComplete?.invoke()
}
/**
* Gets the current highlight information.
*
* @return The current [CoachMarkHighlightState] or null if no highlight is active.
*/
private fun getCurrentHighlight(): CoachMarkHighlightState<T>? {
return currentHighlight.value?.let { highlights[it] }
}
private fun updateCoachMarkStateInternal(highlight: CoachMarkHighlightState<T>?) {
mutableIsVisible.value = highlight != null
mutableCurrentHighlightShape.value = highlight?.shape ?: CoachMarkHighlightShape.SQUARE
if (currentHighlightBounds.value != highlight?.highlightBounds) {
mutableCurrentHighlightBounds.value = highlight?.highlightBounds ?: Rect.Zero
}
}
/**
* Cleans up the tooltip state by dismissing it if visible and calling onDispose.
*/
private fun TooltipState.cleanUp() {
if (isVisible) {
dismiss()
}
onDispose()
}
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates a [Saver] for [CoachMarkState] to enable saving and restoring its state.
*
* @return A [Saver] that can save and restore [CoachMarkState].
*/
inline fun <reified T : Enum<T>> saver(): Saver<CoachMarkState<T>, Any> =
listSaver(
save = { coachMarkState ->
listOf(
coachMarkState.orderedList.map { it.name },
coachMarkState.currentHighlight.value?.name,
coachMarkState.isVisible.value,
)
},
restore = { restoredList ->
val enumList = restoredList[0] as List<*>
val currentHighlightName = restoredList[1] as String?
val enumValues = enumValues<T>()
val list = enumList.mapNotNull { name ->
enumValues.find { it.name == name }
}
val currentHighlight = currentHighlightName?.let { name ->
enumValues.find { it.name == name }
}
val isVisible = restoredList[2] as Boolean
CoachMarkState(
orderedList = list,
initialCoachMarkHighlight = currentHighlight,
isCoachMarkVisible = isVisible,
)
},
)
}
}
/**
* Remembers and saves the state of a [CoachMarkState].
*
* @param T The type of the enum used to represent the coach mark keys.
* @param orderedList The ordered list of coach mark keys.
* @return A [CoachMarkState] instance.
*/
@Composable
inline fun <reified T : Enum<T>> rememberCoachMarkState(orderedList: List<T>): CoachMarkState<T> {
return rememberSaveable(saver = CoachMarkState.saver<T>()) {
CoachMarkState(orderedList)
}
}
/**
* Combine two [Rect] to create the largest result rectangle between them.
* This will include any space between the [Rect] as well.
*/
private fun Rect.union(other: Rect): Rect {
return Rect(
left = min(left, other.left),
top = min(top, other.top),
right = max(right, other.right),
bottom = max(bottom, other.bottom),
)
}

View File

@@ -0,0 +1,175 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
/**
* A [CoachMarkState] that depends on a [LazyListState] to automatically scroll to the current
* Coach Mark if not on currently on the screen.
*/
class LazyListCoachMarkState<T : Enum<T>>(
private val lazyListState: LazyListState,
orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
isCoachMarkVisible: Boolean = false,
) : CoachMarkState<T>(orderedList, initialCoachMarkHighlight, isCoachMarkVisible) {
override suspend fun showCoachMark(coachMarkToShow: T) {
lazyListState.searchForKey(coachMarkToShow)
super.showCoachMark(coachMarkToShow)
}
private suspend fun LazyListState.searchForKey(keyToFind: T) {
layoutInfo
.visibleItemsInfo
.any { it.key == keyToFind }
.takeIf { itemAlreadyVisible ->
if (itemAlreadyVisible) {
val offset =
layoutInfo
.visibleItemsInfo
.find { visItem ->
visItem.key == keyToFind
}
?.offset
when {
offset == null -> Unit
((layoutInfo.viewportEndOffset - offset) <
END_VIEW_PORT_PIXEL_THRESHOLD) -> {
scrollBy(layoutInfo.quarterViewPortScrollAmount())
}
((offset - layoutInfo.viewportStartOffset) <
START_VIEW_PORT_PIXEL_THRESHOLD) -> {
scrollBy(-(layoutInfo.quarterViewPortScrollAmount()))
}
else -> Unit
}
}
itemAlreadyVisible
}
?: scrollUpToKey(keyToFind).takeIf { it }
?: scrollDownToKey(keyToFind)
}
private suspend fun LazyListState.scrollUpToKey(
targetKey: T,
): Boolean {
val scrollAmount = (-1).toFloat()
var found = false
var keepSearching = true
while (keepSearching && !found) {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.any { it.key == targetKey }) {
scrollBy(-(layoutInfo.halfViewPortScrollAmount()))
found = true
} else if (!canScrollBackward) {
keepSearching = false
} else {
this.scrollBy(scrollAmount)
}
}
return found
}
private suspend fun LazyListState.scrollDownToKey(
targetKey: T,
): Boolean {
val scrollAmount = 1.toFloat()
var found = false
var keepSearching = true
while (keepSearching && !found) {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.any { it.key == targetKey }) {
scrollBy(layoutInfo.halfViewPortScrollAmount())
found = true
} else if (!this.canScrollForward) {
// Reached the end of the list without finding the key
keepSearching = false
} else {
this.scrollBy(scrollAmount)
}
}
return found
}
private fun LazyListLayoutInfo.halfViewPortScrollAmount(): Float = when (this.orientation) {
Orientation.Vertical -> (viewportSize.height / 2f)
Orientation.Horizontal -> (viewportSize.width / 2f)
}
@Suppress("MagicNumber")
private fun LazyListLayoutInfo.quarterViewPortScrollAmount(): Float = when (this.orientation) {
Orientation.Vertical -> (viewportSize.height / 4f)
Orientation.Horizontal -> (viewportSize.width / 4f)
}
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates a [Saver] for [CoachMarkState] to enable saving and restoring its state.
*
* @return A [Saver] that can save and restore [CoachMarkState].
*/
inline fun <reified T : Enum<T>> saver(
lazyListState: LazyListState,
): Saver<CoachMarkState<T>, Any> =
listSaver(
save = { coachMarkState ->
listOf(
coachMarkState.orderedList.map { it.name },
coachMarkState.currentHighlight.value?.name,
coachMarkState.isVisible.value,
)
},
restore = { restoredList ->
val enumList = restoredList[0] as List<*>
val currentHighlightName = restoredList[1] as String?
val enumValues = enumValues<T>()
val list = enumList.mapNotNull { name ->
enumValues.find { it.name == name }
}
val currentHighlight = currentHighlightName?.let { name ->
enumValues.find { it.name == name }
}
val isVisible = restoredList[2] as Boolean
LazyListCoachMarkState(
lazyListState = lazyListState,
orderedList = list,
initialCoachMarkHighlight = currentHighlight,
isCoachMarkVisible = isVisible,
)
},
)
}
}
/**
* Remembers and saves the state of a [LazyListCoachMarkState].
*
* @param T The type of the enum used to represent the coach mark keys.
* @param orderedList The ordered list of coach mark keys.
* @param lazyListState The lazy list state to be used by the created instance.
* @return A [LazyListCoachMarkState] instance.
*/
@Composable
inline fun <reified T : Enum<T>> rememberLazyListCoachMarkState(
orderedList: List<T>,
lazyListState: LazyListState,
): CoachMarkState<T> {
return rememberSaveable(saver = LazyListCoachMarkState.saver<T>(lazyListState)) {
LazyListCoachMarkState(lazyListState = lazyListState, orderedList = orderedList)
}
}
private const val END_VIEW_PORT_PIXEL_THRESHOLD = 150
private const val START_VIEW_PORT_PIXEL_THRESHOLD = 40

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark.model
/**
* Defines the available shapes for a coach mark highlight.
*/
enum class CoachMarkHighlightShape {
/**
* A square-shaped highlight.
*/
SQUARE,
/**
* An oval-shaped highlight.
*/
OVAL,
}

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark.model
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.ui.geometry.Rect
/**
* Represents a highlight within a coach mark sequence.
*
* @param T The type of the enum key used to identify the highlight.
* @property key The unique key identifying this highlight.
* @property highlightBounds The rectangular bounds of the area to highlight.
* @property toolTipState The state of the tooltip associated with this highlight.
* @property shape The shape of the highlight (e.g., square, oval).
*/
@OptIn(ExperimentalMaterial3Api::class)
data class CoachMarkHighlightState<T : Enum<T>>(
val key: T,
val highlightBounds: Rect?,
val toolTipState: TooltipState,
val shape: CoachMarkHighlightShape,
)

View File

@@ -0,0 +1,75 @@
package com.x8bit.bitwarden.ui.platform.components.tooltip
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.tooltip.color.bitwardenTooltipColors
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Bitwarden themed rich tool-tip to show within a [TooltipScope].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TooltipScope.BitwardenToolTip(
title: String,
description: String,
modifier: Modifier = Modifier,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
) {
RichTooltip(
modifier = modifier,
caretSize = DpSize(width = 24.dp, height = 16.dp),
shape = BitwardenTheme.shapes.coachmark,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = title,
style = BitwardenTheme.typography.eyebrowMedium,
)
Spacer(modifier = Modifier.weight(1f))
onDismiss?.let {
BitwardenStandardIconButton(
painter = rememberVectorPainter(R.drawable.ic_close_small),
contentDescription = stringResource(R.string.close),
onClick = it,
modifier = Modifier.offset(x = 16.dp, y = (-16).dp),
)
}
}
},
action = {
Row(
Modifier.fillMaxWidth(),
) {
leftAction?.invoke(this)
Spacer(modifier = Modifier.weight(1f))
rightAction?.invoke(this)
}
},
colors = bitwardenTooltipColors(),
) {
Text(
text = description,
style = BitwardenTheme.typography.bodyMedium,
)
}
}

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.ui.platform.components.tooltip.color
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.RichTooltipColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTip
/**
* Bitwarden themed colors for the [BitwardenToolTip]
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun bitwardenTooltipColors(
contentColor: Color = BitwardenTheme.colorScheme.text.primary,
containerColor: Color = BitwardenTheme.colorScheme.background.secondary,
titleContentColor: Color = BitwardenTheme.colorScheme.text.secondary,
actionContentColor: Color = BitwardenTheme.colorScheme.text.interaction,
): RichTooltipColors = RichTooltipColors(
contentColor = contentColor,
containerColor = containerColor,
titleContentColor = titleContentColor,
actionContentColor = actionContentColor,
)

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@@ -15,6 +16,7 @@ import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
@@ -31,7 +33,7 @@ import kotlinx.collections.immutable.toImmutableList
*/
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun VaultAddEditContent(
fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
state: VaultAddEditState.ViewState.Content,
isAddItemMode: Boolean,
typeOptions: List<VaultAddEditState.ItemTypeOption>,
@@ -42,7 +44,12 @@ fun VaultAddEditContent(
cardItemTypeHandlers: VaultAddEditCardTypeHandlers,
sshKeyItemTypeHandlers: VaultAddEditSshKeyTypeHandlers,
modifier: Modifier = Modifier,
lazyListState: LazyListState,
permissionsManager: PermissionsManager,
onNextCoachMark: () -> Unit,
onPreviousCoachMark: () -> Unit,
onCoachMarkTourComplete: () -> Unit,
onCoachMarkDismissed: () -> Unit,
) {
val launcher = permissionsManager.getLauncher(
onResult = { isGranted ->
@@ -58,7 +65,7 @@ fun VaultAddEditContent(
},
)
LazyColumn(modifier = modifier) {
LazyColumn(modifier = modifier, state = lazyListState) {
if (state.isIndividualVaultDisabled && isAddItemMode) {
item {
Spacer(modifier = Modifier.height(height = 12.dp))
@@ -111,6 +118,11 @@ fun VaultAddEditContent(
launcher.launch(Manifest.permission.CAMERA)
}
},
coachMarkScope = this@VaultAddEditContent,
onPreviousCoachMark = onPreviousCoachMark,
onNextCoachMark = onNextCoachMark,
onCoachMarkTourComplete = onCoachMarkTourComplete,
onCoachMarkDismissed = onCoachMarkDismissed,
)
}
@@ -183,3 +195,12 @@ private fun TypeOptionsItem(
modifier = modifier,
)
}
/**
* Enumerated values representing the coach mark items to be shown.
*/
enum class AddEditItemCoachMark {
GENERATE_PASSWORD,
TOTP,
URI,
}

View File

@@ -1,12 +1,12 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit
import androidx.compose.foundation.layout.Column
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.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -19,10 +19,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkActionText
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.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenHiddenPasswordField
@@ -46,13 +49,18 @@ import kotlinx.collections.immutable.toImmutableList
*/
@Suppress("LongMethod", "LongParameterList")
fun LazyListScope.vaultAddEditLoginItems(
coachMarkScope: CoachMarkScope<AddEditItemCoachMark>,
commonState: VaultAddEditState.ViewState.Content.Common,
loginState: VaultAddEditState.ViewState.Content.ItemType.Login,
isAddItemMode: Boolean,
commonActionHandler: VaultAddEditCommonHandlers,
loginItemTypeHandlers: VaultAddEditLoginTypeHandlers,
onTotpSetupClick: () -> Unit,
) {
onNextCoachMark: () -> Unit,
onPreviousCoachMark: () -> Unit,
onCoachMarkTourComplete: () -> Unit,
onCoachMarkDismissed: () -> Unit,
) = coachMarkScope.run {
item {
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenTextField(
@@ -78,11 +86,13 @@ fun LazyListScope.vaultAddEditLoginItems(
)
}
item {
item(key = AddEditItemCoachMark.GENERATE_PASSWORD) {
PasswordRow(
password = loginState.password,
canViewPassword = loginState.canViewPassword,
loginItemTypeHandlers = loginItemTypeHandlers,
onGenerateCoachMarkActionClick = onNextCoachMark,
onCoachMarkDismissed = onCoachMarkDismissed,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
@@ -115,12 +125,29 @@ fun LazyListScope.vaultAddEditLoginItems(
Spacer(modifier = Modifier.height(height = 8.dp))
}
if (loginState.totp != null) {
item {
coachMarkHighlightItem(
key = AddEditItemCoachMark.TOTP,
title = R.string.coachmark_2_of_3.asText(),
description = R.string.you_ll_only_need_to_set_up_authenticator_key.asText(),
onDismiss = onCoachMarkDismissed,
leftAction = {
CoachMarkActionText(
actionLabel = stringResource(R.string.back),
onActionClick = onPreviousCoachMark,
)
},
rightAction = {
CoachMarkActionText(
actionLabel = stringResource(R.string.next),
onActionClick = onNextCoachMark,
)
},
modifier = Modifier.standardHorizontalMargin(),
) {
if (loginState.totp != null) {
BitwardenTextFieldWithActions(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.fillMaxWidth(),
label = stringResource(id = R.string.totp),
value = loginState.totp,
trailingIconContent = {
@@ -150,9 +177,7 @@ fun LazyListScope.vaultAddEditLoginItems(
textFieldTestTag = "LoginTotpEntry",
cardStyle = CardStyle.Full,
)
}
} else {
item {
} else {
Spacer(modifier = Modifier.height(16.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.setup_totp),
@@ -160,8 +185,7 @@ fun LazyListScope.vaultAddEditLoginItems(
onClick = onTotpSetupClick,
modifier = Modifier
.testTag("SetupTotpButton")
.fillMaxWidth()
.standardHorizontalMargin(),
.fillMaxWidth(),
)
}
}
@@ -178,27 +202,47 @@ fun LazyListScope.vaultAddEditLoginItems(
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(loginState.uriList) { index, uriItem ->
coachMarkHighlightItems(
key = AddEditItemCoachMark.URI,
title = R.string.coachmark_3_of_3.asText(),
description = R.string.you_must_add_a_web_address_to_use_autofill_to_access_this_account
.asText(),
leftAction = {
CoachMarkActionText(
actionLabel = stringResource(R.string.back),
onActionClick = onPreviousCoachMark,
)
},
onDismiss = onCoachMarkDismissed,
rightAction = {
CoachMarkActionText(
actionLabel = stringResource(R.string.done_text),
onActionClick = onCoachMarkTourComplete,
)
},
trailingStaticContent = {
Column {
Spacer(modifier = Modifier.height(16.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.new_uri),
onClick = loginItemTypeHandlers.onAddNewUriClick,
modifier = Modifier
.testTag("LoginAddNewUriButton")
.fillMaxWidth(),
)
}
},
items = loginState.uriList,
modifier = Modifier
.standardHorizontalMargin(),
) { uriItem, cardStyle ->
VaultAddEditUriItem(
uriItem = uriItem,
onUriValueChange = loginItemTypeHandlers.onUriValueChange,
onUriItemRemoved = loginItemTypeHandlers.onRemoveUriClick,
cardStyle = loginState.uriList.toListItemCardStyle(index = index),
cardStyle = cardStyle,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.new_uri),
onClick = loginItemTypeHandlers.onAddNewUriClick,
modifier = Modifier
.testTag("LoginAddNewUriButton")
.fillMaxWidth()
.standardHorizontalMargin(),
.fillMaxWidth(),
)
}
@@ -447,10 +491,12 @@ private fun UsernameRow(
@Suppress("LongMethod")
@Composable
private fun PasswordRow(
private fun CoachMarkScope<AddEditItemCoachMark>.PasswordRow(
password: String,
canViewPassword: Boolean,
loginItemTypeHandlers: VaultAddEditLoginTypeHandlers,
onGenerateCoachMarkActionClick: () -> Unit,
onCoachMarkDismissed: () -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
@@ -477,18 +523,34 @@ private fun PasswordRow(
onClick = loginItemTypeHandlers.onPasswordCheckerClick,
modifier = Modifier.testTag(tag = "CheckPasswordButton"),
)
BitwardenStandardIconButton(
vectorIconRes = R.drawable.ic_generate,
contentDescription = stringResource(id = R.string.generate_password),
onClick = {
if (password.isEmpty()) {
loginItemTypeHandlers.onOpenPasswordGeneratorClick()
} else {
shouldShowDialog = true
}
CoachMarkHighlight(
key = AddEditItemCoachMark.GENERATE_PASSWORD,
title = stringResource(R.string.coachmark_1_of_3),
description = stringResource(
R.string.use_this_button_to_generate_a_new_unique_password,
),
shape = CoachMarkHighlightShape.OVAL,
onDismiss = onCoachMarkDismissed,
rightAction = {
CoachMarkActionText(
actionLabel = stringResource(R.string.next),
onActionClick = onGenerateCoachMarkActionClick,
)
},
modifier = Modifier.testTag(tag = "RegeneratePasswordButton"),
)
) {
BitwardenStandardIconButton(
vectorIconRes = R.drawable.ic_generate,
contentDescription = stringResource(id = R.string.generate_password),
onClick = {
if (password.isEmpty()) {
loginItemTypeHandlers.onOpenPasswordGeneratorClick()
} else {
shouldShowDialog = true
}
},
modifier = Modifier.testTag(tag = "RegeneratePasswordButton"),
)
}
if (shouldShowDialog) {
BitwardenTwoButtonDialog(

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
@@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -28,6 +30,8 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.appbar.action.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkContainer
import com.x8bit.bitwarden.ui.platform.components.coachmark.rememberLazyListCoachMarkState
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
@@ -56,6 +60,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditIdentit
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditUserVerificationHandlers
import kotlinx.coroutines.launch
/**
* Top level composable for the vault add item screen.
@@ -84,6 +89,11 @@ fun VaultAddEditScreen(
VaultAddEditUserVerificationHandlers.create(viewModel = viewModel)
}
val lazyListState = rememberLazyListState()
val coachMarkState = rememberLazyListCoachMarkState(
lazyListState = lazyListState,
orderedList = AddEditItemCoachMark.entries,
)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultAddEditEvent.NavigateToQrCodeScan -> {
@@ -133,6 +143,12 @@ fun VaultAddEditScreen(
onNotSupported = userVerificationHandlers.onUserVerificationNotSupported,
)
}
VaultAddEditEvent.StartAddLoginItemCoachMarkTour -> {
coachMarkState.showCoachMark(
coachMarkToShow = AddEditItemCoachMark.GENERATE_PASSWORD,
)
}
}
}
@@ -247,112 +263,145 @@ fun VaultAddEditScreen(
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = state.screenDisplayName(),
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.CloseClick) }
},
)
.takeIf { state.shouldShowCloseButton },
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) }
},
modifier = Modifier.testTag("SaveButton"),
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOfNotNull(
OverflowMenuItemData(
text = stringResource(id = R.string.attachments),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.AttachmentsClick,
)
}
},
)
.takeUnless { state.isAddItemMode },
OverflowMenuItemData(
text = stringResource(id = R.string.move_to_organization),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.MoveToOrganizationClick,
)
}
},
)
.takeUnless { state.isAddItemMode || state.isCipherInCollection },
OverflowMenuItemData(
text = stringResource(id = R.string.collections),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.CollectionsClick,
)
}
},
)
.takeUnless {
state.isAddItemMode ||
!state.isCipherInCollection ||
!state.canAssociateToCollections
},
OverflowMenuItemData(
text = stringResource(id = R.string.delete),
onClick = { pendingDeleteCipher = true },
)
.takeUnless { state.isAddItemMode || !state.canDelete },
),
)
},
)
},
val coroutineScope = rememberCoroutineScope()
val scrollBackToTop: () -> Unit = remember {
{
coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
}
CoachMarkContainer(
state = coachMarkState,
) {
when (val viewState = state.viewState) {
is VaultAddEditState.ViewState.Content -> {
VaultAddEditContent(
state = viewState,
isAddItemMode = state.isAddItemMode,
typeOptions = state.supportedItemTypes,
onTypeOptionClicked = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.TypeOptionSelect(it)) }
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = state.screenDisplayName(),
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.CloseClick) }
},
)
.takeIf { state.shouldShowCloseButton },
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) }
},
modifier = Modifier.testTag("SaveButton"),
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOfNotNull(
OverflowMenuItemData(
text = stringResource(id = R.string.attachments),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.AttachmentsClick,
)
}
},
)
.takeUnless { state.isAddItemMode },
OverflowMenuItemData(
text = stringResource(id = R.string.move_to_organization),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.MoveToOrganizationClick,
)
}
},
)
.takeUnless {
state.isAddItemMode || state.isCipherInCollection
},
OverflowMenuItemData(
text = stringResource(id = R.string.collections),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.CollectionsClick,
)
}
},
)
.takeUnless {
state.isAddItemMode ||
!state.isCipherInCollection ||
!state.canAssociateToCollections
},
OverflowMenuItemData(
text = stringResource(id = R.string.delete),
onClick = { pendingDeleteCipher = true },
)
.takeUnless { state.isAddItemMode || !state.canDelete },
),
)
},
loginItemTypeHandlers = loginItemTypeHandlers,
commonTypeHandlers = commonTypeHandlers,
permissionsManager = permissionsManager,
identityItemTypeHandlers = identityItemTypeHandlers,
cardItemTypeHandlers = cardItemTypeHandlers,
sshKeyItemTypeHandlers = sshKeyItemTypeHandlers,
modifier = Modifier
.imePadding()
.fillMaxSize(),
)
}
},
) {
when (val viewState = state.viewState) {
is VaultAddEditState.ViewState.Content -> {
VaultAddEditContent(
state = viewState,
isAddItemMode = state.isAddItemMode,
typeOptions = state.supportedItemTypes,
onTypeOptionClicked = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.TypeOptionSelect(it),
)
}
},
loginItemTypeHandlers = loginItemTypeHandlers,
commonTypeHandlers = commonTypeHandlers,
permissionsManager = permissionsManager,
identityItemTypeHandlers = identityItemTypeHandlers,
cardItemTypeHandlers = cardItemTypeHandlers,
sshKeyItemTypeHandlers = sshKeyItemTypeHandlers,
lazyListState = lazyListState,
onPreviousCoachMark = {
coroutineScope.launch {
coachMarkState.showPreviousCoachMark()
}
},
onNextCoachMark = {
coroutineScope.launch {
coachMarkState.showNextCoachMark()
}
},
onCoachMarkTourComplete = {
coachMarkState.coachingComplete(onComplete = scrollBackToTop)
},
onCoachMarkDismissed = scrollBackToTop,
modifier = Modifier
.imePadding()
.fillMaxSize(),
)
}
is VaultAddEditState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = Modifier.fillMaxSize(),
)
}
is VaultAddEditState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = Modifier.fillMaxSize(),
)
}
VaultAddEditState.ViewState.Loading -> {
BitwardenLoadingContent(
modifier = Modifier.fillMaxSize(),
)
VaultAddEditState.ViewState.Loading -> {
BitwardenLoadingContent(
modifier = Modifier.fillMaxSize(),
)
}
}
}
}

View File

@@ -2535,6 +2535,11 @@ sealed class VaultAddEditEvent {
data class Fido2UserVerification(
val isRequired: Boolean,
) : BackgroundEvent, VaultAddEditEvent()
/**
* Start the coach mark guided tour of the add login content.
*/
data object StartAddLoginItemCoachMarkTour : VaultAddEditEvent()
}
/**

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M2.22,2.218C2.513,1.926 2.987,1.926 3.28,2.218L7.999,6.938L12.719,2.218C13.012,1.926 13.486,1.926 13.779,2.218C14.072,2.511 14.072,2.986 13.779,3.279L9.06,7.998L13.779,12.717C14.072,13.01 14.072,13.485 13.779,13.778C13.486,14.071 13.012,14.071 12.719,13.778L7.999,9.059L3.28,13.778C2.987,14.071 2.513,14.071 2.22,13.778C1.927,13.485 1.927,13.01 2.22,12.717L6.939,7.998L2.22,3.279C1.927,2.986 1.927,2.511 2.22,2.218Z"
android:fillColor="#5A6D91"/>
</vector>

View File

@@ -1114,4 +1114,10 @@ Do you want to switch to this account?</string>
<string name="you_can_change_your_account_email_on_the_bitwarden_web_app">You can change your account email on the Bitwarden web app.</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="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="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>
</resources>

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.printToString
import androidx.compose.ui.text.LinkAnnotation
import com.x8bit.bitwarden.ui.platform.components.coachmark.IsCoachMarkToolTipKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.jupiter.api.assertThrows
@@ -45,6 +46,15 @@ val isProgressBar: SemanticsMatcher
?: false
}
/**
* A [SemanticsMatcher] user to find Popup nodes used specifically for CoachMarkToolTips
*/
val isCoachMarkToolTip: SemanticsMatcher
get() = SemanticsMatcher("Node is used to show tool tip for active coach mark.") {
it.config
.getOrNull(IsCoachMarkToolTipKey) == true
}
/**
* Asserts that no dialog currently exists.
*/

View File

@@ -48,6 +48,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.isCoachMarkToolTip
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onAllNodesWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onAllNodesWithTextAfterScroll
@@ -70,6 +71,7 @@ import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -3450,6 +3452,114 @@ class VaultAddEditScreenTest : BaseComposeTest() {
}
}
@Test
fun `CoachMark tour starts when StartAddLoginItemCoachMarkTour event received`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
composeTestRule
.onNodeWithText("1 of 3")
.assertIsDisplayed()
}
@Test
fun `CoachMark tour able to move forward and backward between coach marks`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
composeTestRule
.onNodeWithText("1 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Next")
.performClick()
composeTestRule
.onNodeWithText("1 of 3")
.assertIsNotDisplayed()
composeTestRule
.onNodeWithText("2 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Back")
.performClick()
composeTestRule
.onNodeWithText("2 of 3")
.assertIsNotDisplayed()
composeTestRule
.onNodeWithText("1 of 3")
.assertIsDisplayed()
}
@Test
fun `Clicking close on a coach mark should end the tour`() = runTest {
mutableStateFlow.value = DEFAULT_STATE_LOGIN
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
composeTestRule
.onNodeWithText("1 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Next")
.performClick()
composeTestRule
.onNodeWithText("2 of 3")
.assertIsDisplayed()
composeTestRule
.onNode(
hasAnyAncestor(isCoachMarkToolTip) and
hasContentDescription("Close"),
)
.performClick()
composeTestRule
.onNode(isCoachMarkToolTip)
.assertDoesNotExist()
}
@Test
fun `CoachMark tour is closed when user clicks done on final coach mark`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN
mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
composeTestRule
.onNodeWithText("1 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Next")
.performClick()
composeTestRule
.onNodeWithText("1 of 3")
.assertIsNotDisplayed()
composeTestRule
.onNodeWithText("2 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Next")
.performClick()
composeTestRule
.onNodeWithText("2 of 3")
.assertIsNotDisplayed()
composeTestRule
.onNodeWithText("3 of 3")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Done")
.performClick()
composeTestRule
.onNode(isCoachMarkToolTip)
.assertDoesNotExist()
}
//region Helper functions
private fun updateLoginType(