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 b97f257483..5d3ad85b24 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 @@ -46,6 +46,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R @@ -68,7 +69,9 @@ import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MAX_NUMBER_OF_WORDS import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MIN_NUMBER_OF_WORDS @@ -92,6 +95,7 @@ fun GeneratorScreen( viewModel: GeneratorViewModel = hiltViewModel(), onNavigateToPasswordHistory: () -> Unit, onNavigateBack: () -> Unit, + intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -109,6 +113,12 @@ fun GeneratorScreen( ) } + is GeneratorEvent.NavigateToTooltip -> { + intentManager.launchUri( + "https://bitwarden.com/help/generator/#username-types".toUri(), + ) + } + GeneratorEvent.NavigateBack -> onNavigateBack.invoke() } } @@ -155,6 +165,10 @@ fun GeneratorScreen( PassphraseHandlers.create(viewModel = viewModel) } + val usernameTypeHandlers = remember(viewModel) { + UsernameTypeHandlers.create(viewModel = viewModel) + } + val forwardedEmailAliasHandlers = remember(viewModel) { ForwardedEmailAliasHandlers.create(viewModel = viewModel) } @@ -216,6 +230,7 @@ fun GeneratorScreen( onUsernameSubStateOptionClicked = onUsernameOptionClicked, passwordHandlers = passwordHandlers, passphraseHandlers = passphraseHandlers, + usernameTypeHandlers = usernameTypeHandlers, forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, @@ -287,6 +302,7 @@ private fun ScrollContent( onUsernameSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, passwordHandlers: PasswordHandlers, passphraseHandlers: PassphraseHandlers, + usernameTypeHandlers: UsernameTypeHandlers, forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, @@ -339,6 +355,7 @@ private fun ScrollContent( is GeneratorState.MainType.Username -> { UsernameTypeItems( usernameState = selectedType, + usernameTypeHandlers = usernameTypeHandlers, onSubStateOptionClicked = onUsernameSubStateOptionClicked, forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, plusAddressedEmailHandlers = plusAddressedEmailHandlers, @@ -821,12 +838,13 @@ private fun PassphraseIncludeNumberToggleItem( private fun ColumnScope.UsernameTypeItems( usernameState: GeneratorState.MainType.Username, onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, + usernameTypeHandlers: UsernameTypeHandlers, forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, randomWordHandlers: RandomWordHandlers, ) { - UsernameOptionsItem(usernameState, onSubStateOptionClicked) + UsernameOptionsItem(usernameState, onSubStateOptionClicked, usernameTypeHandlers) when (val selectedType = usernameState.selectedType) { is GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail -> { @@ -863,6 +881,7 @@ private fun ColumnScope.UsernameTypeItems( private fun UsernameOptionsItem( currentSubState: GeneratorState.MainType.Username, onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, + usernameTypeHandlers: UsernameTypeHandlers, ) { val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.entries val optionsWithStrings = possibleSubStates.associateWith { stringResource(id = it.labelRes) } @@ -884,9 +903,7 @@ private fun UsernameOptionsItem( stringResource(id = it) }, tooltip = TooltipData( - onClick = { - // TODO: "?" icon redirects user to appropriate link (BIT-1087) - }, + onClick = usernameTypeHandlers.onUsernameTooltipClicked, contentDescription = stringResource(id = R.string.learn_more), ), ) @@ -1308,6 +1325,28 @@ private data class PassphraseHandlers( } } +/** + * A class dedicated to handling user interactions related to all username configurations. + * Each lambda corresponds to a specific user action, allowing for easy delegation of + * logic when user input is detected. + */ +@Suppress("LongParameterList") +private data class UsernameTypeHandlers( + val onUsernameTooltipClicked: () -> Unit, +) { + companion object { + fun create(viewModel: GeneratorViewModel): UsernameTypeHandlers { + return UsernameTypeHandlers( + onUsernameTooltipClicked = { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.TooltipClick, + ) + }, + ) + } + } +} + /** * A class dedicated to handling user interactions related to forwarded email alias * configuration. 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 e98d20e522..5aa8baa7bc 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 @@ -163,6 +163,10 @@ class GeneratorViewModel @Inject constructor( handleUsernameTypeOptionSelect(action) } + is GeneratorAction.MainType.Username.UsernameType.TooltipClick -> { + handleTooltipClick() + } + is GeneratorAction.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOptionSelect -> { handleServiceTypeOptionSelect(action) } @@ -503,6 +507,10 @@ class GeneratorViewModel @Inject constructor( clipboardManager.setText(text = state.generatedText) } + private fun handleTooltipClick() { + sendEvent(GeneratorEvent.NavigateToTooltip) + } + private fun handleUpdateGeneratedPasswordResult( action: GeneratorAction.Internal.UpdateGeneratedPasswordResult, ) { @@ -2023,6 +2031,11 @@ sealed class GeneratorAction { */ sealed class UsernameType : Username() { + /** + * Represents the action to learn more. + */ + data object TooltipClick : GeneratorAction() + /** * Represents actions specifically related to Forwarded Email Alias. */ @@ -2230,6 +2243,11 @@ sealed class GeneratorEvent { */ data object NavigateBack : GeneratorEvent() + /** + * Navigate back to learn more screen. + */ + data object NavigateToTooltip : GeneratorEvent() + /** * Displays the message in a snackbar. */ 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 40fb1009e0..bddaf6f26b 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 @@ -10,10 +10,12 @@ import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -24,12 +26,16 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.compose.ui.text.AnnotatedString +import androidx.core.net.toUri import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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 io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before @@ -47,6 +53,9 @@ class GeneratorScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val intentManager: IntentManager = mockk { + every { launchUri(any()) } just runs + } @Before fun setup() { @@ -55,6 +64,7 @@ class GeneratorScreenTest : BaseComposeTest() { viewModel = viewModel, onNavigateToPasswordHistory = { onNavigateToPasswordHistoryScreenCalled = true }, onNavigateBack = {}, + intentManager = intentManager, ) } } @@ -1177,6 +1187,43 @@ class GeneratorScreenTest : BaseComposeTest() { //endregion SimpleLogin Service Type Tests + //region Username Type Tests + + @Test + fun `in Username state, clicking the toolitp icon should send the TooltipClick action`() { + updateState(DEFAULT_STATE.copy(selectedType = GeneratorState.MainType.Username())) + + composeTestRule + .onNodeWithContentDescription( + label = "Username type, Plus addressed email", + useUnmergedTree = true, + ) + // Find the button + .onChildren() + .filterToOne(hasClickAction()) + // Find the content description + .onChildren() + .filterToOne(hasContentDescription("Learn more")) + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.TooltipClick, + ) + } + } + + @Test + fun `on NavigateToTooltip should call launchUri on IntentManager`() { + mutableEventFlow.tryEmit(GeneratorEvent.NavigateToTooltip) + verify { + intentManager.launchUri("https://bitwarden.com/help/generator/#username-types".toUri()) + } + } + + //endregion Username Type Tests + //region Username Plus Addressed Email Tests @Suppress("MaxLineLength") 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 be1967f9d8..ff83dfcf21 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 @@ -527,6 +527,18 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Test + fun `TooltipClick should emit NavigateToTooltip event`() = runTest { + val viewModel = createViewModel(initialUsernameState) + + viewModel.actionChannel.trySend(GeneratorAction.MainType.Username.UsernameType.TooltipClick) + + viewModel.eventFlow.test { + val event = awaitItem() + assertEquals(GeneratorEvent.NavigateToTooltip, event) + } + } + @Nested inner class PasswordActions { private val defaultPasswordState = createPasswordState()