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 index e69a4a4a95..1185858df6 100644 --- 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 @@ -38,8 +38,6 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import kotlinx.coroutines.launch -private const val ROUNDED_RECT_RADIUS = 8f - /** * A composable container that manages and displays coach mark highlights. * @@ -79,17 +77,17 @@ fun > CoachMarkContainer( bottomRight = boundedRectangle.bottomRight, ) Path().apply { - when (currentHighlightShape) { - CoachMarkHighlightShape.SQUARE -> addRoundRect( + when (val shape = currentHighlightShape) { + is CoachMarkHighlightShape.RoundedRectangle -> addRoundRect( RoundRect( rect = highlightArea, cornerRadius = CornerRadius( - x = ROUNDED_RECT_RADIUS, + x = shape.radius, ), ), ) - CoachMarkHighlightShape.OVAL -> addOval(highlightArea) + CoachMarkHighlightShape.Oval -> addOval(highlightArea) } } } @@ -189,7 +187,7 @@ private fun BitwardenCoachMarkContainer_preview() { style = BitwardenTheme.typography.labelLarge, ) }, - shape = CoachMarkHighlightShape.OVAL, + shape = CoachMarkHighlightShape.Oval, ) { BitwardenStandardIconButton( painter = rememberVectorPainter(R.drawable.ic_puzzle), @@ -203,6 +201,7 @@ private fun BitwardenCoachMarkContainer_preview() { key = Foo.Baz, title = "Foo", description = "Baz", + shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), leftAction = { BitwardenClickableText( label = "Back", 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 index 85d195ad18..b159e6ef9f 100644 --- 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 @@ -53,7 +53,7 @@ interface CoachMarkScope> { title: String, description: String, modifier: Modifier = Modifier, - shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE, + shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(), onDismiss: (() -> Unit)? = null, leftAction: (@Composable RowScope.() -> Unit)? = null, rightAction: (@Composable RowScope.() -> Unit)? = null, @@ -81,7 +81,7 @@ interface CoachMarkScope> { title: Text, description: Text, modifier: Modifier = Modifier, - shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE, + shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(), onDismiss: (() -> Unit)? = null, leftAction: (@Composable RowScope.() -> Unit)? = null, rightAction: (@Composable RowScope.() -> Unit)? = null, @@ -112,7 +112,7 @@ interface CoachMarkScope> { title: Text, description: Text, modifier: Modifier = Modifier, - shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE, + shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(), items: List, onDismiss: (() -> Unit)? = null, leftAction: (@Composable RowScope.() -> Unit)? = null, 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 index 2fa1bf09e0..d9df759cd1 100644 --- 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 @@ -38,7 +38,9 @@ open class CoachMarkState>( val currentHighlight: State = mutableCurrentHighlight private val mutableCurrentHighlightBounds = mutableStateOf(Rect.Zero) val currentHighlightBounds: State = mutableCurrentHighlightBounds - private val mutableCurrentHighlightShape = mutableStateOf(CoachMarkHighlightShape.SQUARE) + private val mutableCurrentHighlightShape = mutableStateOf( + CoachMarkHighlightShape.RoundedRectangle(), + ) val currentHighlightShape: State = mutableCurrentHighlightShape private val mutableIsVisible = mutableStateOf(isCoachMarkVisible) @@ -53,13 +55,13 @@ open class CoachMarkState>( * Rect.Zero. * @param toolTipState The state of the tooltip associated with this highlight. * @param shape The shape of the highlight (e.g., square, oval). Defaults to - * [CoachMarkHighlightShape.SQUARE]. + * [CoachMarkHighlightShape.RoundedRectangle]. */ fun updateHighlight( key: T, bounds: Rect?, toolTipState: BitwardenToolTipState, - shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE, + shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(), ) { highlights[key] = CoachMarkHighlightState( key = key, @@ -168,7 +170,7 @@ open class CoachMarkState>( getCurrentHighlight()?.toolTipState?.cleanUp() mutableCurrentHighlight.value = null mutableCurrentHighlightBounds.value = Rect.Zero - mutableCurrentHighlightShape.value = CoachMarkHighlightShape.SQUARE + mutableCurrentHighlightShape.value = CoachMarkHighlightShape.RoundedRectangle() mutableIsVisible.value = false onComplete?.invoke() } @@ -184,7 +186,8 @@ open class CoachMarkState>( private fun updateCoachMarkStateInternal(highlight: CoachMarkHighlightState?) { mutableIsVisible.value = highlight != null - mutableCurrentHighlightShape.value = highlight?.shape ?: CoachMarkHighlightShape.SQUARE + mutableCurrentHighlightShape.value = + highlight?.shape ?: CoachMarkHighlightShape.RoundedRectangle() if (currentHighlightBounds.value != highlight?.highlightBounds) { mutableCurrentHighlightBounds.value = highlight?.highlightBounds ?: Rect.Zero } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt index 54bf2fcd63..196a461914 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightShape.kt @@ -1,16 +1,23 @@ package com.x8bit.bitwarden.ui.platform.components.coachmark.model +private const val ROUNDED_RECT_DEFAULT_RADIUS = 8f + /** * Defines the available shapes for a coach mark highlight. */ -enum class CoachMarkHighlightShape { +sealed class CoachMarkHighlightShape { /** - * A square-shaped highlight. + * A rounded rectangle shape which has a radius to round the corners by. + * + * @property radius the radius to use to round the corners of the rectangle shape. + * Defaults to [ROUNDED_RECT_DEFAULT_RADIUS] */ - SQUARE, + data class RoundedRectangle( + val radius: Float = ROUNDED_RECT_DEFAULT_RADIUS, + ) : CoachMarkHighlightShape() /** * An oval-shaped highlight. */ - OVAL, + data object Oval : CoachMarkHighlightShape() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt index 59874ac36e..b7f7143733 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/segment/BitwardenSegmentedButton.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -39,6 +40,14 @@ fun BitwardenSegmentedButton( windowInsets: WindowInsets = WindowInsets.displayCutout .union(WindowInsets.navigationBars) .only(WindowInsetsSides.Horizontal), + optionContent: @Composable SingleChoiceSegmentedButtonRowScope.( + Int, + SegmentedButtonState, + ) -> Unit = { _, optionState -> + this.SegmentedButtonOptionContent( + option = optionState, + ) + }, ) { if (options.isEmpty()) return Box( @@ -58,29 +67,39 @@ fun BitwardenSegmentedButton( space = 0.dp, ) { options.forEachIndexed { index, option -> - SegmentedButton( - enabled = option.isEnabled, - selected = option.isChecked, - onClick = option.onClick, - colors = bitwardenSegmentedButtonColors(), - shape = BitwardenTheme.shapes.segmentedControl, - border = BorderStroke(width = 0.dp, color = Color.Transparent), - label = { - Text( - text = option.text, - style = BitwardenTheme.typography.labelLarge, - ) - }, - icon = { - // No icon required - }, - modifier = Modifier.semantics { option.testTag?.let { testTag = it } }, - ) + optionContent(index, option) } } } } +/** + * Default content definition for each option in a [BitwardenSegmentedButton]. + */ +@Composable +fun SingleChoiceSegmentedButtonRowScope.SegmentedButtonOptionContent( + option: SegmentedButtonState, +) { + SegmentedButton( + enabled = option.isEnabled, + selected = option.isChecked, + onClick = option.onClick, + colors = bitwardenSegmentedButtonColors(), + shape = BitwardenTheme.shapes.segmentedControl, + border = BorderStroke(width = 0.dp, color = Color.Transparent), + label = { + Text( + text = option.text, + style = BitwardenTheme.typography.labelLarge, + ) + }, + icon = { + // No icon required + }, + modifier = Modifier.semantics { option.testTag?.let { testTag = it } }, + ) +} + /** * Models state for an individual button in a [BitwardenSegmentedButton]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index d40df2294c..07ec1754c5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned @@ -172,7 +173,7 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToSetupAutoFillScreen: () -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { - var shouldDimNavBar by remember { mutableStateOf(false) } + var shouldDimNavBar by rememberSaveable { mutableStateOf(false) } // This scaffold will host screens that contain top bars while not hosting one itself. // We need to ignore the all insets here and let the content screens handle it themselves. @@ -239,6 +240,9 @@ private fun VaultUnlockedNavBarScaffold( ) generatorGraph( onNavigateToPasswordHistory = { navigateToPasswordHistory() }, + onDimNavBarRequest = { shouldDim -> + shouldDimNavBar = shouldDim + }, ) settingsGraph( navController = navController, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorGraphNavigation.kt index a0d5a29ae7..4ebc34ba6b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorGraphNavigation.kt @@ -12,6 +12,7 @@ const val GENERATOR_GRAPH_ROUTE: String = "generator_graph" */ fun NavGraphBuilder.generatorGraph( onNavigateToPasswordHistory: () -> Unit, + onDimNavBarRequest: (Boolean) -> Unit, ) { navigation( route = GENERATOR_GRAPH_ROUTE, @@ -19,6 +20,7 @@ fun NavGraphBuilder.generatorGraph( ) { generatorDestination( onNavigateToPasswordHistory = onNavigateToPasswordHistory, + onDimNavBarRequest = onDimNavBarRequest, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorNavigation.kt index 99d78c2235..5892bfb56b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorNavigation.kt @@ -48,11 +48,13 @@ data class GeneratorArgs( */ fun NavGraphBuilder.generatorDestination( onNavigateToPasswordHistory: () -> Unit, + onDimNavBarRequest: (Boolean) -> Unit, ) { composable(GENERATOR_ROUTE) { GeneratorScreen( onNavigateToPasswordHistory = onNavigateToPasswordHistory, onNavigateBack = {}, + onDimNavBarRequest = onDimNavBarRequest, ) } } @@ -76,6 +78,7 @@ fun NavGraphBuilder.generatorModalDestination( GeneratorScreen( onNavigateToPasswordHistory = {}, onNavigateBack = onNavigateBack, + onDimNavBarRequest = {}, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 9dc0de437d..59c98ee34b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -18,9 +20,12 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -46,6 +51,11 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconBu import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard +import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkActionText +import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkContainer +import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope +import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape +import com.x8bit.bitwarden.ui.platform.components.coachmark.rememberLazyListCoachMarkState import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField @@ -56,6 +66,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.segment.BitwardenSegmentedButton +import com.x8bit.bitwarden.ui.platform.components.segment.SegmentedButtonOptionContent import com.x8bit.bitwarden.ui.platform.components.segment.SegmentedButtonState import com.x8bit.bitwarden.ui.platform.components.slider.BitwardenSlider import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData @@ -86,10 +97,12 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberPasswordH import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberPlusAddressedEmailHandlers import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberRandomWordHandlers import com.x8bit.bitwarden.ui.tools.feature.generator.handlers.rememberUsernameTypeHandlers +import com.x8bit.bitwarden.ui.tools.feature.generator.model.ExploreGeneratorCoachMark import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import kotlin.math.max /** @@ -102,6 +115,7 @@ fun GeneratorScreen( viewModel: GeneratorViewModel = hiltViewModel(), onNavigateToPasswordHistory: () -> Unit, onNavigateBack: () -> Unit, + onDimNavBarRequest: (Boolean) -> Unit, intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -116,6 +130,15 @@ fun GeneratorScreen( } } + val lazyListState = rememberLazyListState() + val coachMarkState = rememberLazyListCoachMarkState( + orderedList = ExploreGeneratorCoachMark.entries, + lazyListState = lazyListState, + ) + + LaunchedEffect(key1 = coachMarkState.isVisible.value) { + onDimNavBarRequest(coachMarkState.isVisible.value) + } EventsEffect(viewModel = viewModel) { event -> when (event) { GeneratorEvent.NavigateToPasswordHistory -> onNavigateToPasswordHistory() @@ -134,9 +157,11 @@ fun GeneratorScreen( } GeneratorEvent.NavigateBack -> onNavigateBack.invoke() + GeneratorEvent.StartCoachMarkTour -> { + coachMarkState.showCoachMark(ExploreGeneratorCoachMark.PASSWORD_MODE) + } } } - val onRegenerateClick: () -> Unit = remember(viewModel) { { viewModel.trySendAction(GeneratorAction.RegenerateClick) } } @@ -158,6 +183,25 @@ fun GeneratorScreen( } } + val scope = rememberCoroutineScope() + val onShowNextCoachMark: () -> Unit = remember { + { + scope.launch { coachMarkState.showNextCoachMark() } + } + } + + val onShowPreviousCoachMark: () -> Unit = remember { + { + scope.launch { coachMarkState.showPreviousCoachMark() } + } + } + + val onDismissCoachMark: () -> Unit = remember { + { + scope.launch { lazyListState.animateScrollToItem(index = 0) } + } + } + val passwordHandlers = rememberPasswordHandlers(viewModel) val passphraseHandlers = rememberPassphraseHandlers(viewModel) val usernameTypeHandlers = rememberUsernameTypeHandlers(viewModel) @@ -167,60 +211,74 @@ fun GeneratorScreen( val randomWordHandlers = rememberRandomWordHandlers(viewModel) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - BitwardenScaffold( - topBar = { - when (val generatorMode = state.generatorMode) { - is GeneratorMode.Modal -> { - ModalAppBar( - generatorMode = generatorMode, - scrollBehavior = scrollBehavior, - onCloseClick = remember(viewModel) { - { viewModel.trySendAction(GeneratorAction.CloseClick) } - }, - onSelectClick = remember(viewModel) { - { viewModel.trySendAction(GeneratorAction.SelectClick) } - }, - ) - } - - GeneratorMode.Default -> { - DefaultAppBar( - scrollBehavior = scrollBehavior, - onPasswordHistoryClick = remember(viewModel) { - { viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) } - }, - ) - } - } - }, - utilityBar = { - MainStateOptionsItem( - selectedType = state.selectedType, - passcodePolicyOverride = state.passcodePolicyOverride, - possibleMainStates = state.typeOptions.toImmutableList(), - onMainStateOptionClicked = onMainStateOptionClicked, - modifier = Modifier - .scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior), - ) - }, - snackbarHost = { - BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + CoachMarkContainer( + state = coachMarkState, + modifier = Modifier.fillMaxSize(), ) { - ScrollContent( - state = state, - onRegenerateClick = onRegenerateClick, - onCopyClick = onCopyClick, - onUsernameSubStateOptionClicked = onUsernameOptionClicked, - passwordHandlers = passwordHandlers, - passphraseHandlers = passphraseHandlers, - usernameTypeHandlers = usernameTypeHandlers, - forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, - plusAddressedEmailHandlers = plusAddressedEmailHandlers, - catchAllEmailHandlers = catchAllEmailHandlers, - randomWordHandlers = randomWordHandlers, - ) + BitwardenScaffold( + topBar = { + when (val generatorMode = state.generatorMode) { + is GeneratorMode.Modal -> { + ModalAppBar( + generatorMode = generatorMode, + scrollBehavior = scrollBehavior, + onCloseClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.CloseClick) } + }, + onSelectClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.SelectClick) } + }, + ) + } + + GeneratorMode.Default -> { + DefaultAppBar( + scrollBehavior = scrollBehavior, + onPasswordHistoryClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) } + }, + ) + } + } + }, + utilityBar = { + MainStateOptionsItem( + selectedType = state.selectedType, + passcodePolicyOverride = state.passcodePolicyOverride, + possibleMainStates = state.typeOptions.toImmutableList(), + onMainStateOptionClicked = onMainStateOptionClicked, + onShowNextCoachMark = onShowNextCoachMark, + onShowPreviousCoachMark = onShowPreviousCoachMark, + onDismissCoachMark = onDismissCoachMark, + modifier = Modifier + .scrolledContainerBottomDivider(topAppBarScrollBehavior = scrollBehavior), + ) + }, + snackbarHost = { + BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + ScrollContent( + state = state, + onRegenerateClick = onRegenerateClick, + onCopyClick = onCopyClick, + onUsernameSubStateOptionClicked = onUsernameOptionClicked, + passwordHandlers = passwordHandlers, + passphraseHandlers = passphraseHandlers, + usernameTypeHandlers = usernameTypeHandlers, + forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, + plusAddressedEmailHandlers = plusAddressedEmailHandlers, + catchAllEmailHandlers = catchAllEmailHandlers, + randomWordHandlers = randomWordHandlers, + ) + } + } + // Remove dim nav bar effect when we leave this screen. + DisposableEffect(Unit) { + onDispose { + onDimNavBarRequest(false) + } } } @@ -419,12 +477,16 @@ private fun GeneratedStringItem( ) } +@Suppress("MaxLineLength", "LongMethod") @Composable -private fun MainStateOptionsItem( +private fun CoachMarkScope.MainStateOptionsItem( selectedType: GeneratorState.MainType, passcodePolicyOverride: GeneratorState.PasscodePolicyOverride?, possibleMainStates: ImmutableList, onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit, + onShowNextCoachMark: () -> Unit, + onShowPreviousCoachMark: () -> Unit, + onDismissCoachMark: () -> Unit, modifier: Modifier = Modifier, ) { BitwardenSegmentedButton( @@ -460,7 +522,85 @@ private fun MainStateOptionsItem( modifier = modifier .fillMaxWidth() .testTag(tag = "GeneratorTypePicker"), - ) + ) { index, option -> + when (index) { + 0 -> { + CoachMarkHighlight( + key = ExploreGeneratorCoachMark.PASSWORD_MODE, + title = stringResource(R.string.coachmark_1_of_6), + description = stringResource( + R.string.use_the_generator_to_create_secure_passwords_passphrases_and_usernames, + ), + onDismiss = onDismissCoachMark, + rightAction = { + CoachMarkActionText( + actionLabel = stringResource(R.string.next), + onActionClick = onShowNextCoachMark, + ) + }, + shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), + ) { + SegmentedButtonOptionContent(option = option) + } + } + + 1 -> { + CoachMarkHighlight( + key = ExploreGeneratorCoachMark.PASSPHRASE_MODE, + title = stringResource(R.string.coachmark_2_of_6), + description = stringResource( + R.string.passphrases_are_strong_passwords_that_are_often_easier_to_remember_and_type_than_random_passwords, + ), + onDismiss = onDismissCoachMark, + rightAction = { + CoachMarkActionText( + actionLabel = stringResource(R.string.next), + onActionClick = onShowNextCoachMark, + ) + }, + leftAction = { + CoachMarkActionText( + actionLabel = stringResource(R.string.back), + onActionClick = onShowPreviousCoachMark, + ) + }, + shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), + ) { + SegmentedButtonOptionContent(option = option) + } + } + + 2 -> { + CoachMarkHighlight( + key = ExploreGeneratorCoachMark.USERNAME_MODE, + title = stringResource(R.string.coachmark_3_of_6), + description = stringResource( + R.string.unique_usernames_add_an_extra_layer_of_security_and_can_help_prevent_hackers_from_finding_your_accounts, + ), + onDismiss = onDismissCoachMark, + rightAction = { + CoachMarkActionText( + actionLabel = stringResource(R.string.next), + onActionClick = onShowNextCoachMark, + ) + }, + leftAction = { + CoachMarkActionText( + actionLabel = stringResource(R.string.back), + onActionClick = onShowPreviousCoachMark, + ) + }, + shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), + ) { + SegmentedButtonOptionContent(option = option) + } + } + + else -> { + SegmentedButtonOptionContent(option = option) + } + } + } } //endregion ScrollContent and Static Items @@ -1210,6 +1350,7 @@ private fun Generator_preview() { GeneratorScreen( onNavigateToPasswordHistory = {}, onNavigateBack = {}, + onDimNavBarRequest = {}, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index b762063fc4..2ba4a7a842 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -159,7 +159,7 @@ class GeneratorViewModel @Inject constructor( private fun handleStartExploreGeneratorTour() { coachMarkTourCompleted() - // TODO: PM-16622 send show coach mark event. + sendEvent(GeneratorEvent.StartCoachMarkTour) } private fun coachMarkTourCompleted() { @@ -2647,6 +2647,11 @@ sealed class GeneratorEvent { data class ShowSnackbar( val message: Text, ) : GeneratorEvent() + + /** + * Triggers the start of showing the coach mark tour. + */ + data object StartCoachMarkTour : GeneratorEvent() } @Suppress("ComplexCondition", "MaxLineLength") diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/ExploreGeneratorCoachMark.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/ExploreGeneratorCoachMark.kt new file mode 100644 index 0000000000..345cfa5147 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/ExploreGeneratorCoachMark.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.ui.tools.feature.generator.model + +/** + * Enumerated values representing the keys for each coach mark to show on the GeneratorScreen. + */ +enum class ExploreGeneratorCoachMark { + PASSWORD_MODE, + PASSPHRASE_MODE, + USERNAME_MODE, + PASSWORD_OPTIONS, + GENERATE_BUTTON, + COPY_PASSWORD_BUTTON, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt index 6e444f8c4a..90f28396c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt @@ -529,7 +529,7 @@ private fun CoachMarkScope.PasswordRow( description = stringResource( R.string.use_this_button_to_generate_a_new_unique_password, ), - shape = CoachMarkHighlightShape.OVAL, + shape = CoachMarkHighlightShape.Oval, onDismiss = onCoachMarkDismissed, rightAction = { CoachMarkActionText( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2684a1f4d0..5f652ec75c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1117,10 +1117,10 @@ Do you want to switch to this account? Login Credentials Autofill Options Use this button to generate a new unique password. - 1 of 3 - 2 of 3 + 1 OF 3 + 2 OF 3 You’ll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in. - 3 of 3 + 3 OF 3 You must add a web address to use autofill to access this account. Mutual TLS Single tap passkey creation @@ -1132,4 +1132,10 @@ Do you want to switch to this account? Import client certificate Enter the client certificate password and the desired alias for this certificate. Alias + "Use the generator to create secure passwords, passphrases and usernames. " + Passphrases are strong passwords that are often easier to remember and type than random passwords. They are helpful for logging into accounts where AutoFill is not available, like a streaming service on your TV. + Unique usernames add an extra layer of security and can help prevent hackers from finding your accounts. + 1 OF 6 + 2 OF 6 + 3 OF 6 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 99c8c69b0b..7bed57aaf5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode +import com.x8bit.bitwarden.ui.util.isCoachMarkToolTip import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -47,6 +48,7 @@ import org.junit.jupiter.api.Assertions.assertTrue @Suppress("LargeClass") class GeneratorScreenTest : BaseComposeTest() { private var onNavigateToPasswordHistoryScreenCalled = false + private var onDimNavBarRequest: Boolean? = null private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -66,6 +68,7 @@ class GeneratorScreenTest : BaseComposeTest() { viewModel = viewModel, onNavigateToPasswordHistory = { onNavigateToPasswordHistoryScreenCalled = true }, onNavigateBack = {}, + onDimNavBarRequest = { onDimNavBarRequest = it }, intentManager = intentManager, ) } @@ -1606,6 +1609,97 @@ class GeneratorScreenTest : BaseComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `when StartCoachMarkTour event is received the first coach mark is shown and onDimNavBarRequest sends value of true `() { + mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour) + + composeTestRule + .onNodeWithText("1 OF 6") + .assertIsDisplayed() + + assertTrue(onDimNavBarRequest == true) + } + + @Suppress("MaxLineLength") + @Test + fun `when a coach mark close button is clicked no coach mark should be showing and onDimNavBarRequest sends the value of false`() { + mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour) + + composeTestRule + .onNodeWithText("1 OF 6") + .assertIsDisplayed() + + composeTestRule + .onNode( + hasContentDescription("Close") and + hasAnyAncestor(isCoachMarkToolTip), + ) + .performClick() + + composeTestRule + .onNode(isCoachMarkToolTip) + .assertDoesNotExist() + + assertTrue(onDimNavBarRequest == false) + } + + @Suppress("MaxLineLength") + @Test + fun `when a coach mark next button is clicked should progress to the next coach mark`() { + mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour) + + composeTestRule + .onNodeWithText("1 OF 6") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Next") + .performClick() + + composeTestRule + .onNodeWithText("1 OF 6") + .assertDoesNotExist() + + composeTestRule + .onNodeWithText("2 OF 6") + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `when a coach mark back button is clicked should return to previous coach mark`() { + mutableEventFlow.tryEmit(GeneratorEvent.StartCoachMarkTour) + + composeTestRule + .onNodeWithText("1 OF 6") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Next") + .performClick() + + composeTestRule + .onNodeWithText("1 OF 6") + .assertDoesNotExist() + + composeTestRule + .onNodeWithText("2 OF 6") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Back") + .performClick() + + composeTestRule + .onNodeWithText("2 OF 6") + .assertDoesNotExist() + + composeTestRule + .onNodeWithText("1 OF 6") + .assertIsDisplayed() + } + //endregion Random Word Tests private fun updateState(state: GeneratorState) { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index b257e7e798..7c99befc20 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -2227,9 +2227,13 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `StartExploreGeneratorTour action calls first time action manager hasSeenGeneratorCoachMarkTour called and show coach mark event sent`() { + fun `StartExploreGeneratorTour action calls first time action manager markCoachMarkTourCompleted called and show coach mark event sent`() = + runTest { val viewModel = createViewModel() - viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour) + viewModel.eventFlow.test { + viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour) + assertEquals(GeneratorEvent.StartCoachMarkTour, awaitItem()) + } verify(exactly = 1) { firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 892fdc7100..713489845c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -3458,7 +3458,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { mutableStateFlow.value = DEFAULT_STATE_LOGIN mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsDisplayed() } @@ -3467,18 +3467,18 @@ class VaultAddEditScreenTest : BaseComposeTest() { mutableStateFlow.value = DEFAULT_STATE_LOGIN mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsDisplayed() composeTestRule .onNodeWithText("Next") .performClick() composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsNotDisplayed() composeTestRule - .onNodeWithText("2 of 3") + .onNodeWithText("2 OF 3") .assertIsDisplayed() composeTestRule @@ -3486,11 +3486,11 @@ class VaultAddEditScreenTest : BaseComposeTest() { .performClick() composeTestRule - .onNodeWithText("2 of 3") + .onNodeWithText("2 OF 3") .assertIsNotDisplayed() composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsDisplayed() } @@ -3499,14 +3499,14 @@ class VaultAddEditScreenTest : BaseComposeTest() { mutableStateFlow.value = DEFAULT_STATE_LOGIN mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsDisplayed() composeTestRule .onNodeWithText("Next") .performClick() composeTestRule - .onNodeWithText("2 of 3") + .onNodeWithText("2 OF 3") .assertIsDisplayed() composeTestRule @@ -3526,18 +3526,18 @@ class VaultAddEditScreenTest : BaseComposeTest() { mutableStateFlow.value = DEFAULT_STATE_LOGIN mutableEventFlow.tryEmit(VaultAddEditEvent.StartAddLoginItemCoachMarkTour) composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsDisplayed() composeTestRule .onNodeWithText("Next") .performClick() composeTestRule - .onNodeWithText("1 of 3") + .onNodeWithText("1 OF 3") .assertIsNotDisplayed() composeTestRule - .onNodeWithText("2 of 3") + .onNodeWithText("2 OF 3") .assertIsDisplayed() composeTestRule @@ -3545,11 +3545,11 @@ class VaultAddEditScreenTest : BaseComposeTest() { .performClick() composeTestRule - .onNodeWithText("2 of 3") + .onNodeWithText("2 OF 3") .assertIsNotDisplayed() composeTestRule - .onNodeWithText("3 of 3") + .onNodeWithText("3 OF 3") .assertIsDisplayed() composeTestRule