From dfeeeb26bfcbdb014b62d18d6dd9b1d5ff435d8e Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 15 Sep 2026 15:41:38 +0000 Subject: [PATCH] PM-43002: feat: Update inline generator UI (#7394) --- .../feature/generator/GeneratorScreen.kt | 135 ++++++++++++------ .../feature/generator/GeneratorScreenTest.kt | 133 ++++++++++++++--- ui/src/main/res/values/strings.xml | 3 + 3 files changed, 205 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 89d9500ef1..4c8f463701 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -49,7 +49,6 @@ import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData import com.bitwarden.ui.platform.components.appbar.model.TopAppBarDividerStyle import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton -import com.bitwarden.ui.platform.components.button.BitwardenTextButton import com.bitwarden.ui.platform.components.button.model.BitwardenButtonData import com.bitwarden.ui.platform.components.button.model.BitwardenHelpButtonData import com.bitwarden.ui.platform.components.card.BitwardenActionCard @@ -182,9 +181,6 @@ fun GeneratorScreen( } } } - val onRegenerateClick: () -> Unit = { viewModel.trySendAction(GeneratorAction.RegenerateClick) } - - val onCopyClick: () -> Unit = { viewModel.trySendAction(GeneratorAction.CopyClick) } val onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit = { viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(it)) @@ -225,7 +221,6 @@ fun GeneratorScreen( generatorMode = generatorMode, scrollBehavior = scrollBehavior, onCloseClick = { viewModel.trySendAction(GeneratorAction.CloseClick) }, - onSaveClick = { viewModel.trySendAction(GeneratorAction.SaveClick) }, ) } @@ -259,8 +254,9 @@ fun GeneratorScreen( ) { ScrollContent( state = state, - onRegenerateClick = onRegenerateClick, - onCopyClick = onCopyClick, + onRegenerateClick = { viewModel.trySendAction(GeneratorAction.RegenerateClick) }, + onCopyClick = { viewModel.trySendAction(GeneratorAction.CopyClick) }, + onSaveClick = { viewModel.trySendAction(GeneratorAction.SaveClick) }, onUsernameSubStateOptionClicked = onUsernameOptionClicked, passwordHandlers = passwordHandlers, passphraseHandlers = passphraseHandlers, @@ -328,7 +324,6 @@ private fun ModalAppBar( generatorMode: GeneratorMode.Modal, scrollBehavior: TopAppBarScrollBehavior, onCloseClick: () -> Unit, - onSaveClick: () -> Unit, modifier: Modifier = Modifier, ) { BitwardenTopAppBar( @@ -341,13 +336,6 @@ private fun ModalAppBar( GeneratorMode.Modal.Password -> TopAppBarDividerStyle.NONE is GeneratorMode.Modal.Username -> TopAppBarDividerStyle.ON_SCROLL }, - actions = { - BitwardenTextButton( - label = stringResource(id = BitwardenString.apply), - onClick = onSaveClick, - modifier = Modifier.testTag("SaveButton"), - ) - }, modifier = modifier, ) } @@ -362,6 +350,7 @@ private fun CoachMarkScope.ScrollContent( state: GeneratorState, onRegenerateClick: () -> Unit, onCopyClick: () -> Unit, + onSaveClick: () -> Unit, onUsernameSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, passwordHandlers: PasswordHandlers, passphraseHandlers: PassphraseHandlers, @@ -461,6 +450,7 @@ private fun CoachMarkScope.ScrollContent( onShowPreviousCoachMark = onShowPreviousCoachMark, onDismissCoachMark = onDismissCoachMark, onShowNextCoachMark = onShowNextCoachMark, + onCopyClick = onCopyClick.takeIf { state.generatorMode is GeneratorMode.Modal }, modifier = Modifier .standardHorizontalMargin() .fillMaxWidth(), @@ -469,40 +459,84 @@ private fun CoachMarkScope.ScrollContent( Spacer(modifier = Modifier.height(12.dp)) } - @Suppress("MaxLineLength") - coachMarkHighlightItem( - key = ExploreGeneratorCoachMark.COPY_PASSWORD_BUTTON, - title = BitwardenString.coachmark_6_of_6.asText(), - description = BitwardenString - .after_you_save_your_new_password_to_bitwarden_don_t_forget_to_update_it_on_your_account_website - .asText(), - shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), - onDismiss = onDismissCoachMark, - leftAction = { - CoachMarkActionText( - actionLabel = stringResource(BitwardenString.back), - onActionClick = onShowPreviousCoachMark, - ) - }, - rightAction = { - CoachMarkActionText( - actionLabel = stringResource(BitwardenString.done_text), - onActionClick = onCoachMarkComplete, - ) - }, - modifier = Modifier.standardHorizontalMargin(windowAdaptiveInfo = windowAdaptiveInfo), - ) { - BitwardenFilledButton( - label = stringResource(id = BitwardenString.copy), - onClick = onCopyClick, - modifier = Modifier - .testTag(tag = "CopyValueButton") - .fillMaxWidth(), - ) + when (state.generatorMode) { + GeneratorMode.Default -> { + @Suppress("MaxLineLength") + coachMarkHighlightItem( + key = ExploreGeneratorCoachMark.COPY_PASSWORD_BUTTON, + title = BitwardenString.coachmark_6_of_6.asText(), + description = BitwardenString + .after_you_save_your_new_password_to_bitwarden_don_t_forget_to_update_it_on_your_account_website + .asText(), + shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f), + onDismiss = onDismissCoachMark, + leftAction = { + CoachMarkActionText( + actionLabel = stringResource(id = BitwardenString.back), + onActionClick = onShowPreviousCoachMark, + ) + }, + rightAction = { + CoachMarkActionText( + actionLabel = stringResource(id = BitwardenString.done_text), + onActionClick = onCoachMarkComplete, + ) + }, + modifier = Modifier + .standardHorizontalMargin(windowAdaptiveInfo = windowAdaptiveInfo), + ) { + BitwardenFilledButton( + label = stringResource(id = BitwardenString.copy), + onClick = onCopyClick, + modifier = Modifier + .testTag(tag = "CopyValueButton") + .fillMaxWidth(), + ) + } + } + + is GeneratorMode.Modal -> { + item(key = "use_value_button") { + when (state.selectedType) { + is GeneratorState.MainType.Passphrase -> { + BitwardenFilledButton( + label = stringResource(id = BitwardenString.use_this_passphrase), + onClick = onSaveClick, + modifier = Modifier + .testTag(tag = "UsePassphraseButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } + + is GeneratorState.MainType.Password -> { + BitwardenFilledButton( + label = stringResource(id = BitwardenString.use_this_password), + onClick = onSaveClick, + modifier = Modifier + .testTag(tag = "UsePasswordButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } + + is GeneratorState.MainType.Username -> { + BitwardenFilledButton( + label = stringResource(id = BitwardenString.use_this_username), + onClick = onSaveClick, + modifier = Modifier + .testTag(tag = "UseUsernameButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } + } + } + } } item { - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(height = 24.dp)) } when (val selectedType = state.selectedType) { @@ -574,6 +608,7 @@ private fun CoachMarkScope.GeneratedStringItem( onShowPreviousCoachMark: () -> Unit, onDismissCoachMark: () -> Unit, onShowNextCoachMark: () -> Unit, + onCopyClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { BitwardenTextField( @@ -610,6 +645,14 @@ private fun CoachMarkScope.GeneratedStringItem( modifier = Modifier.testTag("RegenerateValueButton"), ) } + onCopyClick?.let { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_copy, + contentDescription = stringResource(id = BitwardenString.copy), + onClick = it, + modifier = Modifier.testTag(tag = "CopyValueButton"), + ) + } }, onValueChange = {}, readOnly = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 4e968fcc06..924b4ed8c9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -86,27 +86,137 @@ class GeneratorScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithContentDescription(label = "Close") .assertIsDisplayed() + } + + @Test + fun `on use this password click should send SaveClick`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Password)) composeTestRule - .onNodeWithText(text = "Apply") + .onNodeWithText(text = "Use this password") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.SaveClick) + } + } + + @Test + fun `on copy icon click with password mode should send CopyClick`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Password, + selectedType = GeneratorState.MainType.Password(), + ), + ) + + composeTestRule + .onNodeWithContentDescription(label = "Copy") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.CopyClick) + } + } + + @Test + fun `ModalAppBar should be displayed for Passphrase Mode`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Password, + selectedType = GeneratorState.MainType.Passphrase(), + ), + ) + + composeTestRule + .onNodeWithContentDescription(label = "Close") .assertIsDisplayed() } + @Test + fun `on use this passphrase click should send SaveClick`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Password, + selectedType = GeneratorState.MainType.Passphrase(), + ), + ) + + composeTestRule + .onNodeWithText(text = "Use this passphrase") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.SaveClick) + } + } + + @Test + fun `on copy icon click with passphrase mode should send CopyClick`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Password, + selectedType = GeneratorState.MainType.Passphrase(), + ), + ) + + composeTestRule + .onNodeWithContentDescription(label = "Copy") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.CopyClick) + } + } + @Test fun `ModalAppBar should be displayed for Username Mode`() { updateState( DEFAULT_STATE.copy( generatorMode = GeneratorMode.Modal.Username(website = null), + selectedType = GeneratorState.MainType.Username(), ), ) composeTestRule .onNodeWithContentDescription(label = "Close") .assertIsDisplayed() + } + + @Test + fun `on use this username click should send SaveClick`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Username(website = null), + selectedType = GeneratorState.MainType.Username(), + ), + ) composeTestRule - .onNodeWithText(text = "Apply") - .assertIsDisplayed() + .onNodeWithText(text = "Use this username") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.SaveClick) + } + } + + @Test + fun `on copy icon click with username mode should send CopyClick`() { + updateState( + DEFAULT_STATE.copy( + generatorMode = GeneratorMode.Modal.Username(website = null), + selectedType = GeneratorState.MainType.Username(), + ), + ) + + composeTestRule + .onNodeWithContentDescription(label = "Copy") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.CopyClick) + } } @Test @@ -126,23 +236,6 @@ class GeneratorScreenTest : BitwardenComposeTest() { } } - @Test - fun `on Apply click should send SaveClick`() { - updateState( - DEFAULT_STATE.copy( - generatorMode = GeneratorMode.Modal.Username(website = null), - ), - ) - - composeTestRule - .onNodeWithText(text = "Apply") - .performClick() - - verify { - viewModel.trySendAction(GeneratorAction.SaveClick) - } - } - @Test fun `DefaultAppBar should be displayed for Default Mode`() { updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Default)) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 281b36b0e3..f38700c934 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1443,4 +1443,7 @@ Do you want to switch to this account? %1$s only allows invited users to create accounts. Decrease %1$s Increase %1$s + Use this passphrase + Use this password + Use this username