diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ModifierExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ModifierExtensions.kt index ab841b9a80f..5c5aed8710b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ModifierExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ModifierExtensions.kt @@ -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() { + 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) + } + } + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkActionText.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkActionText.kt new file mode 100644 index 00000000000..2682ce4ba06 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkActionText.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt new file mode 100644 index 00000000000..199257ac1d8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt @@ -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 > CoachMarkContainer( + state: CoachMarkState, + modifier: Modifier = Modifier, + content: @Composable CoachMarkScope.() -> 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, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScope.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScope.kt new file mode 100644 index 00000000000..85d195ad18f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScope.kt @@ -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> { + + /** + * 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 LazyListScope.coachMarkHighlightItems( + key: T, + title: Text, + description: Text, + modifier: Modifier = Modifier, + shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE, + items: List, + 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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt new file mode 100644 index 00000000000..f28a3e3a04f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt @@ -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>( + private val coachMarkState: CoachMarkState, +) : CoachMarkScope { + + @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 LazyListScope.coachMarkHighlightItems( + key: T, + title: Text, + description: Text, + modifier: Modifier, + shape: CoachMarkHighlightShape, + items: List, + 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 Collection.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("IsCoachMarkToolTip") +private var SemanticsPropertyReceiver.isCoachMarkToolTip by IsCoachMarkToolTipKey diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt new file mode 100644 index 00000000000..c833f8cbcee --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt @@ -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>( + val orderedList: List, + initialCoachMarkHighlight: T? = null, + isCoachMarkVisible: Boolean = false, +) { + private val highlights: MutableMap?> = ConcurrentHashMap() + private val mutableCurrentHighlight = mutableStateOf(initialCoachMarkHighlight) + val currentHighlight: State = mutableCurrentHighlight + private val mutableCurrentHighlightBounds = mutableStateOf(Rect.Zero) + val currentHighlightBounds: State = mutableCurrentHighlightBounds + private val mutableCurrentHighlightShape = mutableStateOf(CoachMarkHighlightShape.SQUARE) + val currentHighlightShape: State = mutableCurrentHighlightShape + + private val mutableIsVisible = mutableStateOf(isCoachMarkVisible) + val isVisible: State = 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? { + return currentHighlight.value?.let { highlights[it] } + } + + private fun updateCoachMarkStateInternal(highlight: CoachMarkHighlightState?) { + 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 > saver(): Saver, 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() + 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 > rememberCoachMarkState(orderedList: List): CoachMarkState { + return rememberSaveable(saver = CoachMarkState.saver()) { + 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), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt new file mode 100644 index 00000000000..1973efe6b31 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt @@ -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>( + private val lazyListState: LazyListState, + orderedList: List, + initialCoachMarkHighlight: T? = null, + isCoachMarkVisible: Boolean = false, +) : CoachMarkState(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 > saver( + lazyListState: LazyListState, + ): Saver, 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() + 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 > rememberLazyListCoachMarkState( + orderedList: List, + lazyListState: LazyListState, +): CoachMarkState { + return rememberSaveable(saver = LazyListCoachMarkState.saver(lazyListState)) { + LazyListCoachMarkState(lazyListState = lazyListState, orderedList = orderedList) + } +} + +private const val END_VIEW_PORT_PIXEL_THRESHOLD = 150 +private const val START_VIEW_PORT_PIXEL_THRESHOLD = 40 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt new file mode 100644 index 00000000000..54bf2fcd63d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt @@ -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, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt new file mode 100644 index 00000000000..da0ec745f9d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt @@ -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>( + val key: T, + val highlightBounds: Rect?, + val toolTipState: TooltipState, + val shape: CoachMarkHighlightShape, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTip.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTip.kt new file mode 100644 index 00000000000..57d91e4c979 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTip.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/color/BitwardenToolTipColors.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/color/BitwardenToolTipColors.kt new file mode 100644 index 00000000000..e227873296d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/color/BitwardenToolTipColors.kt @@ -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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt index 60bdd156d40..a11795b87cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt @@ -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.VaultAddEditContent( state: VaultAddEditState.ViewState.Content, isAddItemMode: Boolean, typeOptions: List, @@ -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, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt index db510cbfe4a..c3860d144c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt @@ -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, 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), - modifier = Modifier - .fillMaxWidth() - .standardHorizontalMargin(), - ) - } - - item { - Spacer(modifier = Modifier.height(16.dp)) - BitwardenOutlinedButton( - label = stringResource(id = R.string.new_uri), - onClick = loginItemTypeHandlers.onAddNewUriClick, + cardStyle = cardStyle, modifier = Modifier - .testTag("LoginAddNewUriButton") - .fillMaxWidth() - .standardHorizontalMargin(), + .fillMaxWidth(), ) } @@ -447,10 +491,12 @@ private fun UsernameRow( @Suppress("LongMethod") @Composable -private fun PasswordRow( +private fun CoachMarkScope.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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index f49ba699bf2..7e6ebbcefad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -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) } + val coroutineScope = rememberCoroutineScope() + val scrollBackToTop: () -> Unit = remember { + { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } + } + CoachMarkContainer( + state = coachMarkState, + ) { + 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) } }, - 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 }, - ), - ) - }, - ) - }, - ) { - 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)) } + .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(), + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 1db05825da3..322b1dd0818 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -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() } /** diff --git a/app/src/main/res/drawable/ic_close_small.xml b/app/src/main/res/drawable/ic_close_small.xml new file mode 100644 index 00000000000..cb8149415df --- /dev/null +++ b/app/src/main/res/drawable/ic_close_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac599d2b783..671934d024f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1114,4 +1114,10 @@ Do you want to switch to this account? You can change your account email on the Bitwarden web app. Login Credentials Autofill Options + Use this button to generate a new unique password. + 1 of 3 + 2 of 3 + You’ll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in. + 3 of 3 + You must add a web address to use autofill to access this account. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index c9229dff574..41867db70cf 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index d5dad2c05a3..00eba61a2bd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -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(