From 4b5f16d4073aa9382b0571c28ab9beef3ed3c3ca Mon Sep 17 00:00:00 2001 From: Dave Severns <dseverns@livefront.com> Date: Fri, 3 Jan 2025 14:51:42 -0500 Subject: [PATCH] PM-16631 Adding coach mark container and state to allow for guided screen tours --- .../coachmark/CoachMarkContainer.kt | 246 +++++++++ .../components/coachmark/CoachMarkScope.kt | 125 +++++ .../coachmark/CoachMarkScopeInstance.kt | 362 ++++++++++++++ .../components/coachmark/CoachMarkState.kt | 465 ++++++++++++++++++ 4 files changed, 1198 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScope.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt 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 000000000..b172f6e76 --- /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.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, +} 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 000000000..936b66a0f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScope.kt @@ -0,0 +1,125 @@ +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.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.coachMarkHighlight( + 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, + ) +} 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 000000000..6ffc45695 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt @@ -0,0 +1,362 @@ +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.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipScope +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.composed +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +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.DpSize +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.button.BitwardenStandardIconButton +import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +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.coachMarkHighlight( + 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) + val topCardAlreadyExists = hasLeadingContent && leadingContentIsTopCard + val bottomCardAlreadyExists = (trailingStaticContent != null) && trailingContentIsBottomCard + 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?.let { it() } ?: run { + if (items.isNotEmpty()) { + itemContent( + items[0], + items.toCoachMarkListItemCardStyle( + index = 0, + topCardAlreadyExists = false, + bottomCardAlreadyExists = bottomCardAlreadyExists, + ), + ) + } + } + } + } + } + itemsIndexed(items) { index, item -> + // if there is no leading content we already added the first item. + if (!hasLeadingContent && index == 0) return@itemsIndexed + Box( + modifier = modifier.calculateBoundsAndAddForKey(key), + ) { + val cardStyle = items.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 = { + CoachMarkToolTip( + title = title, + description = description, + onDismiss = { + coachMarkState.coachingComplete() + onDismiss?.invoke() + }, + leftAction = leftAction, + rightAction = rightAction, + ) + }, + enableUserInput = false, + focusable = false, + state = toolTipState, + modifier = modifier, + content = anchorContent, + ) + + LaunchedEffect(Unit) { + coachMarkState.updateHighlight( + key = key, + bounds = null, + toolTipState = toolTipState, + shape = shape, + ) + } + } + + private fun Modifier.calculateBoundsAndAddForKey( + key: T, + isFirstItem: Boolean = false, + ): Modifier = composed { + var bounds by remember { + mutableStateOf(Rect.Zero) + } + LaunchedEffect(bounds) { + if (bounds != Rect.Zero) { + coachMarkState.addToExistingBounds( + key = key, + isFirstItem = isFirstItem, + additionalBounds = bounds, + ) + } + } + this.onGloballyPositioned { + bounds = it.boundsInRoot() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TooltipScope.CoachMarkToolTip( + title: String, + description: String, + onDismiss: (() -> Unit), + leftAction: (@Composable RowScope.() -> Unit)?, + rightAction: (@Composable RowScope.() -> Unit)?, +) { + RichTooltip( + modifier = Modifier + .padding(horizontal = 4.dp) + .semantics { isCoachMarkToolTip = true }, + caretSize = DpSize(width = 24.dp, height = 16.dp), + title = { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = title, + style = BitwardenTheme.typography.eyebrowMedium, + color = BitwardenTheme.colorScheme.text.secondary, + ) + Spacer(modifier = Modifier.weight(1f)) + BitwardenStandardIconButton( + painter = rememberVectorPainter(R.drawable.ic_close), + contentDescription = stringResource(R.string.close), + onClick = onDismiss, + modifier = Modifier.size(16.dp), + ) + } + }, + action = { + Row( + Modifier.fillMaxWidth(), + ) { + leftAction?.invoke(this) + Spacer(modifier = Modifier.weight(1f)) + rightAction?.invoke(this) + } + }, + colors = TooltipDefaults.richTooltipColors( + containerColor = BitwardenTheme.colorScheme.background.secondary, + ), + ) { + Text( + text = description, + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + ) + } +} + +/** + * 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 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 000000000..a18385dc0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt @@ -0,0 +1,465 @@ +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.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 timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.max +import kotlin.math.min + +/** + * 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, +) + +/** + * Defines the available shapes for a coach mark highlight. + */ +enum class CoachMarkHighlightShape { + /** + * A square-shaped highlight. + */ + SQUARE, + + /** + * An oval-shaped highlight. + */ + OVAL, +} + +/** + * 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) { + val highlight = highlights[key] + highlight?.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() + highlightToShow?.let { + updateCoachMarkStateInternal(it) + } + } + + /** + * 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) + 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() + currentHighlight?.toolTipState?.cleanUp() ?: return + 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, + ) + }, + ) + } +} + +/** + * 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) { + super.showCoachMark(coachMarkToShow) + lazyListState.searchForKey(coachMarkToShow) + } + + private suspend fun LazyListState.searchForKey(keyToFind: T): Boolean = + 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) + } + } + } + Timber.i("$targetKey has been found: $found by scrolling up.") + 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) + } + } + } + Timber.i("$targetKey has been found: $found by scrolling down.") + return found + } + + @Suppress("MagicNumber") + 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 [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) + } +} + +/** + * 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) + } +} + +/** + * 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), + ) +} + +private const val END_VIEW_PORT_PIXEL_THRESHOLD = 150 +private const val START_VIEW_PORT_PIXEL_THRESHOLD = 40