mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
PM-16631 Applying CoachMarkContainer to the AddLoginItem content. (#4571)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
9
app/src/main/res/drawable/ic_close_small.xml
Normal file
9
app/src/main/res/drawable/ic_close_small.xml
Normal 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>
|
||||
@@ -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">You’ll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in.</string>
|
||||
<string name="coachmark_3_of_3">3 of 3</string>
|
||||
<string name="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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user