BIT-710: Implement Password History UI (#352)

This commit is contained in:
joshua-livefront 2023-12-08 10:12:23 -05:00 committed by Álison Fernandes
parent 40fa3071ae
commit d0205b4b59
14 changed files with 774 additions and 133 deletions

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteac
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination
import com.x8bit.bitwarden.ui.tools.feature.send.navigateToNewSend import com.x8bit.bitwarden.ui.tools.feature.send.navigateToNewSend
import com.x8bit.bitwarden.ui.tools.feature.send.newSendDestination import com.x8bit.bitwarden.ui.tools.feature.send.newSendDestination
import com.x8bit.bitwarden.ui.vault.feature.additem.navigateToVaultAddEditItem import com.x8bit.bitwarden.ui.vault.feature.additem.navigateToVaultAddEditItem
@ -46,6 +48,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
}, },
onNavigateToNewSend = { navController.navigateToNewSend() }, onNavigateToNewSend = { navController.navigateToNewSend() },
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() }, onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
) )
deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
vaultAddEditItemDestination(onNavigateBack = { navController.popBackStack() }) vaultAddEditItemDestination(onNavigateBack = { navController.popBackStack() })
@ -57,5 +60,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
) )
vaultEditItemDestination(onNavigateBack = { navController.popBackStack() }) vaultEditItemDestination(onNavigateBack = { navController.popBackStack() })
newSendDestination(onNavigateBack = { navController.popBackStack() }) newSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
} }
} }

View file

@ -21,12 +21,14 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
/** /**
* Add vault unlocked destination to the root nav graph. * Add vault unlocked destination to the root nav graph.
*/ */
@Suppress("LongParameterList")
fun NavGraphBuilder.vaultUnlockedNavBarDestination( fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem: () -> Unit, onNavigateToVaultAddItem: () -> Unit,
onNavigateToVaultItem: (vaultItemId: String) -> Unit, onNavigateToVaultItem: (vaultItemId: String) -> Unit,
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit, onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPasswordHistory: () -> Unit,
) { ) {
composable( composable(
route = VAULT_UNLOCKED_NAV_BAR_ROUTE, route = VAULT_UNLOCKED_NAV_BAR_ROUTE,
@ -41,6 +43,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultEditItem = onNavigateToVaultEditItem, onNavigateToVaultEditItem = onNavigateToVaultEditItem,
onNavigateToNewSend = onNavigateToNewSend, onNavigateToNewSend = onNavigateToNewSend,
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
) )
} }
} }

View file

@ -71,6 +71,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit, onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPasswordHistory: () -> Unit,
) { ) {
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
navController.apply { navController.apply {
@ -101,6 +102,7 @@ fun VaultUnlockedNavBarScreen(
navigateToVaultAddItem = onNavigateToVaultAddItem, navigateToVaultAddItem = onNavigateToVaultAddItem,
navigateToNewSend = onNavigateToNewSend, navigateToNewSend = onNavigateToNewSend,
navigateToDeleteAccount = onNavigateToDeleteAccount, navigateToDeleteAccount = onNavigateToDeleteAccount,
navigateToPasswordHistory = onNavigateToPasswordHistory,
generatorTabClickedAction = { generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
}, },
@ -132,6 +134,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
navigateToNewSend: () -> Unit, navigateToNewSend: () -> Unit,
navigateToDeleteAccount: () -> Unit, navigateToDeleteAccount: () -> Unit,
navigateToPasswordHistory: () -> Unit,
) { ) {
var shouldDimNavBar by remember { mutableStateOf(false) } var shouldDimNavBar by remember { mutableStateOf(false) }
@ -191,7 +194,9 @@ private fun VaultUnlockedNavBarScaffold(
}, },
) )
sendGraph(onNavigateToNewSend = navigateToNewSend) sendGraph(onNavigateToNewSend = navigateToNewSend)
generatorDestination() generatorDestination(
onNavigateToPasswordHistory = { navigateToPasswordHistory() },
)
settingsGraph( settingsGraph(
navController = navController, navController = navController,
onNavigateToDeleteAccount = navigateToDeleteAccount, onNavigateToDeleteAccount = navigateToDeleteAccount,

View file

@ -20,8 +20,12 @@ fun NavController.navigateToGenerator(navOptions: NavOptions? = null) {
/** /**
* Add generator destination to the root nav graph. * Add generator destination to the root nav graph.
*/ */
fun NavGraphBuilder.generatorDestination() { fun NavGraphBuilder.generatorDestination(
onNavigateToPasswordHistory: () -> Unit,
) {
composable(GENERATOR_ROUTE) { composable(GENERATOR_ROUTE) {
GeneratorScreen() GeneratorScreen(
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
)
} }
} }

View file

@ -60,6 +60,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
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.IconResource
import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.model.TooltipData
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
@ -70,6 +71,7 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MIN import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MIN
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MIN import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MIN
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
/** /**
@ -80,6 +82,7 @@ import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun GeneratorScreen( fun GeneratorScreen(
viewModel: GeneratorViewModel = hiltViewModel(), viewModel: GeneratorViewModel = hiltViewModel(),
onNavigateToPasswordHistory: () -> Unit,
clipboardManager: ClipboardManager = LocalClipboardManager.current, clipboardManager: ClipboardManager = LocalClipboardManager.current,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -89,6 +92,8 @@ fun GeneratorScreen(
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
GeneratorEvent.NavigateToPasswordHistory -> onNavigateToPasswordHistory()
GeneratorEvent.CopyTextToClipboard -> { GeneratorEvent.CopyTextToClipboard -> {
clipboardManager.setText(AnnotatedString(state.generatedText)) clipboardManager.setText(AnnotatedString(state.generatedText))
} }
@ -165,7 +170,20 @@ fun GeneratorScreen(
title = stringResource(id = R.string.generator), title = stringResource(id = R.string.generator),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = { actions = {
BitwardenOverflowActionItem() BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.password_history),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
GeneratorAction.PasswordHistoryClick,
)
}
},
),
),
)
}, },
) )
}, },
@ -904,7 +922,9 @@ private fun RandomWordIncludeNumberToggleItem(
@Composable @Composable
private fun GeneratorPreview() { private fun GeneratorPreview() {
BitwardenTheme { BitwardenTheme {
GeneratorScreen() GeneratorScreen(
onNavigateToPasswordHistory = {},
)
} }
} }

View file

@ -67,6 +67,10 @@ class GeneratorViewModel @Inject constructor(
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
override fun handleAction(action: GeneratorAction) { override fun handleAction(action: GeneratorAction) {
when (action) { when (action) {
is GeneratorAction.PasswordHistoryClick -> {
handlePasswordHistoryClick()
}
is GeneratorAction.RegenerateClick -> { is GeneratorAction.RegenerateClick -> {
handleRegenerationClick() handleRegenerationClick()
} }
@ -119,6 +123,14 @@ class GeneratorViewModel @Inject constructor(
//endregion Initialization and Overrides //endregion Initialization and Overrides
//region Top Level Handlers
private fun handlePasswordHistoryClick() {
sendEvent(GeneratorEvent.NavigateToPasswordHistory)
}
//endregion Top Level Handlers
//region Generation Handlers //region Generation Handlers
private fun loadPasscodeOptions(selectedType: Passcode) { private fun loadPasscodeOptions(selectedType: Passcode) {
@ -1105,6 +1117,11 @@ data class GeneratorState(
*/ */
sealed class GeneratorAction { sealed class GeneratorAction {
/**
* Indicates that the overflow option for password history has been clicked.
*/
data object PasswordHistoryClick : GeneratorAction()
/** /**
* Represents the action to regenerate a new passcode or username. * Represents the action to regenerate a new passcode or username.
*/ */
@ -1375,6 +1392,11 @@ sealed class GeneratorAction {
*/ */
sealed class GeneratorEvent { sealed class GeneratorEvent {
/**
* Navigates to the Password History screen.
*/
data object NavigateToPasswordHistory : GeneratorEvent()
/** /**
* Copies text to the clipboard. * Copies text to the clipboard.
*/ */

View file

@ -0,0 +1,89 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.base.util.withVisualTransformation
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A composable function for displaying a password history list item.
*
* @param label The primary text to be displayed in the list item.
* @param supportingLabel A secondary text displayed below the primary label.
* @param onCopyClick The lambda function to be invoked when the list items icon is clicked.
* @param modifier The [Modifier] to be applied to the list item.
*/
@Composable
fun PasswordHistoryListItem(
label: String,
supportingLabel: String,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = Modifier
.padding(vertical = 16.dp)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label.withVisualTransformation(
visualTransformation = nonLetterColorVisualTransformation(),
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = supportingLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onCopyClick,
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(
painter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun PasswordHistoryListItem_preview() {
BitwardenTheme {
PasswordHistoryListItem(
label = "8gr6uY8CLYQwzr#",
supportingLabel = "8/24/2023 11:07 AM",
onCopyClick = {},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
/**
* The functions below pertain to entry into the [PasswordHistoryScreen].
*/
private const val PASSWORD_HISTORY_ROUTE: String = "password_history"
/**
* Add password history destination to the graph.
*/
fun NavGraphBuilder.passwordHistoryDestination(
onNavigateBack: () -> Unit,
) {
composable(
// TODO: (BIT-617) Allow Password History screen to launch from VaultItemScreen
route = PASSWORD_HISTORY_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
PasswordHistoryScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Password History Screen.
*/
fun NavController.navigateToPasswordHistory(navOptions: NavOptions? = null) {
navigate(PASSWORD_HISTORY_ROUTE, navOptions)
}

View file

@ -0,0 +1,211 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import kotlinx.collections.immutable.persistentListOf
/**
* Displays the password history screen
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PasswordHistoryScreen(
onNavigateBack: () -> Unit,
viewModel: PasswordHistoryViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
EventsEffect(viewModel = viewModel) { event ->
when (event) {
PasswordHistoryEvent.NavigateBack -> onNavigateBack.invoke()
is PasswordHistoryEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.password_history),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(PasswordHistoryAction.CloseClick) }
},
actions = {
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.clear),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
PasswordHistoryAction.PasswordClearClick,
)
}
},
),
),
)
},
)
},
content = { innerPadding ->
when (val viewState = state.viewState) {
is PasswordHistoryState.ViewState.Loading -> {
PasswordHistoryLoading(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is PasswordHistoryState.ViewState.Error -> {
PasswordHistoryError(
state = viewState,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is PasswordHistoryState.ViewState.Empty -> {
PasswordHistoryEmpty(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is PasswordHistoryState.ViewState.Content -> {
PasswordHistoryContent(
state = viewState,
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(innerPadding),
onPasswordCopyClick = { password ->
viewModel.trySendAction(
PasswordHistoryAction.PasswordCopyClick(password),
)
},
)
}
}
},
)
}
@Composable
private fun PasswordHistoryLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun PasswordHistoryContent(
state: PasswordHistoryState.ViewState.Content,
modifier: Modifier = Modifier,
onPasswordCopyClick: (PasswordHistoryState.GeneratedPassword) -> Unit,
) {
LazyColumn(modifier = modifier) {
items(state.passwords) { password ->
PasswordHistoryListItem(
label = password.password,
supportingLabel = password.date,
onCopyClick = { onPasswordCopyClick(password) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.fillMaxWidth(),
)
}
item {
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun PasswordHistoryError(
state: PasswordHistoryState.ViewState.Error,
modifier: Modifier,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun PasswordHistoryEmpty(modifier: Modifier = Modifier) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(id = R.string.no_passwords_to_list),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -0,0 +1,148 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import android.os.Parcelable
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* ViewModel responsible for handling user interactions in the PasswordHistoryScreen.
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class PasswordHistoryViewModel @Inject constructor() :
BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
) {
override fun handleAction(action: PasswordHistoryAction) {
when (action) {
PasswordHistoryAction.CloseClick -> handleCloseClick()
is PasswordHistoryAction.PasswordCopyClick -> handleCopyClick(action.password)
PasswordHistoryAction.PasswordClearClick -> handlePasswordHistoryClearClick()
}
}
private fun handleCloseClick() {
sendEvent(
event = PasswordHistoryEvent.NavigateBack,
)
}
private fun handlePasswordHistoryClearClick() {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
}
private fun handleCopyClick(password: GeneratedPassword) {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
}
}
/**
* Represents the possible states for the password history screen.
*
* @property viewState The current view state of the password history screen.
*/
@Parcelize
data class PasswordHistoryState(
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the password history screen.
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* Loading state for the password history screen.
*/
@Parcelize
data object Loading : ViewState()
/**
* Error state for the password history screen.
*
* @property message The error message to be displayed.
*/
@Parcelize
data class Error(val message: String) : ViewState()
/**
* Empty state for the password history screen.
*/
@Parcelize
data object Empty : ViewState()
/**
* Content state for the password history screen.
*
* @property passwords A list of generated passwords, each with its creation date.
*/
@Parcelize
data class Content(val passwords: List<GeneratedPassword>) : ViewState()
}
/**
* Represents a generated password with its creation date.
*
* @property password The generated password.
* @property date The date when the password was generated.
*/
@Parcelize
data class GeneratedPassword(
val password: String,
val date: String,
) : Parcelable
}
/**
* Defines the set of events that can occur in the password history screen.
*/
sealed class PasswordHistoryEvent {
/**
* Event to show a toast message.
*
* @property message The message to be displayed in the toast.
*/
data class ShowToast(val message: String) : PasswordHistoryEvent()
/**
* Event to navigate back to the previous screen.
*/
data object NavigateBack : PasswordHistoryEvent()
}
/**
* Represents the set of actions that can be performed in the password history screen.
*/
sealed class PasswordHistoryAction {
/**
* Represents the action triggered when a password copy button is clicked.
*
* @param password The [GeneratedPassword] to be copied.
*/
data class PasswordCopyClick(val password: GeneratedPassword) : PasswordHistoryAction()
/**
* Action when the clear passwords button is clicked.
*/
data object PasswordClearClick : PasswordHistoryAction()
/**
* Action when the close button is clicked.
*/
data object CloseClick : PasswordHistoryAction()
}

View file

@ -37,6 +37,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
onNodeWithText("My vault").performClick() onNodeWithText("My vault").performClick()
@ -62,6 +63,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
@ -88,6 +90,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
onNodeWithText("Send").performClick() onNodeWithText("Send").performClick()
@ -113,6 +116,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
@ -139,6 +143,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
onNodeWithText("Generator").performClick() onNodeWithText("Generator").performClick()
@ -164,6 +169,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
@ -190,6 +196,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
onNodeWithText("Settings").performClick() onNodeWithText("Settings").performClick()
@ -215,6 +222,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }

View file

@ -31,10 +31,14 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
@Suppress("LargeClass") @Suppress("LargeClass")
class GeneratorScreenTest : BaseComposeTest() { class GeneratorScreenTest : BaseComposeTest() {
private var onNavigateToPasswordHistoryScreenCalled = false
private val mutableStateFlow = MutableStateFlow( private val mutableStateFlow = MutableStateFlow(
GeneratorState( GeneratorState(
generatedText = "Placeholder", generatedText = "Placeholder",
@ -59,12 +63,24 @@ class GeneratorScreenTest : BaseComposeTest() {
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
} }
@Before
fun setup() {
composeTestRule.setContent {
GeneratorScreen(
viewModel = viewModel,
onNavigateToPasswordHistory = { onNavigateToPasswordHistoryScreenCalled = true },
)
}
}
@Test
fun `NavigateToPasswordHistory event should call onNavigateToPasswordHistoryScreen`() {
mutableEventFlow.tryEmit(GeneratorEvent.NavigateToPasswordHistory)
assertTrue(onNavigateToPasswordHistoryScreenCalled)
}
@Test @Test
fun `Snackbar should be displayed with correct message on ShowSnackbar event`() { fun `Snackbar should be displayed with correct message on ShowSnackbar event`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
mutableEventFlow.tryEmit(GeneratorEvent.ShowSnackbar("Test Snackbar Message".asText())) mutableEventFlow.tryEmit(GeneratorEvent.ShowSnackbar("Test Snackbar Message".asText()))
composeTestRule composeTestRule
@ -74,10 +90,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Test @Test
fun `clicking the Regenerate button should send RegenerateClick action`() { fun `clicking the Regenerate button should send RegenerateClick action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "Generate password") .onNodeWithContentDescription(label = "Generate password")
.performClick() .performClick()
@ -89,10 +101,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Test @Test
fun `clicking the Copy button should send CopyClick action`() { fun `clicking the Copy button should send CopyClick action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "Copy") .onNodeWithContentDescription(label = "Copy")
.performClick() .performClick()
@ -104,10 +112,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Test @Test
fun `clicking a MainStateOption should send MainTypeOptionSelect action`() { fun `clicking a MainStateOption should send MainTypeOptionSelect action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Opens the menu // Opens the menu
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "What would you like to generate?, Password") .onNodeWithContentDescription(label = "What would you like to generate?, Password")
@ -135,10 +139,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Test @Test
fun `clicking a PasscodeOption should send PasscodeTypeOption action`() { fun `clicking a PasscodeOption should send PasscodeTypeOption action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Opens the menu // Opens the menu
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "Password type, Password") .onNodeWithContentDescription(label = "Password type, Password")
@ -178,10 +178,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Opens the menu // Opens the menu
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "Username type, Plus addressed email") .onNodeWithContentDescription(label = "Username type, Plus addressed email")
@ -212,10 +208,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Test @Test
fun `in Passcode_Password state, the ViewModel state should update the UI correctly`() { fun `in Passcode_Password state, the ViewModel state should update the UI correctly`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithContentDescription(label = "What would you like to generate?, Password") .onNodeWithContentDescription(label = "What would you like to generate?, Password")
.assertIsDisplayed() .assertIsDisplayed()
@ -275,10 +267,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, adjusting the slider should send SliderLengthChange action with length not equal to default`() { fun `in Passcode_Password state, adjusting the slider should send SliderLengthChange action with length not equal to default`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithText("Length") .onNodeWithText("Length")
.onSiblings() .onSiblings()
@ -308,10 +296,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, toggling the capital letters toggle should send ToggleCapitalLettersChange action`() { fun `in Passcode_Password state, toggling the capital letters toggle should send ToggleCapitalLettersChange action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("A—Z") composeTestRule.onNodeWithText("A—Z")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -328,10 +312,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, toggling the use lowercase toggle should send ToggleLowercaseLettersChange action`() { fun `in Passcode_Password state, toggling the use lowercase toggle should send ToggleLowercaseLettersChange action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("a—z") composeTestRule.onNodeWithText("a—z")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -353,10 +333,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, toggling the use numbers toggle should send ToggleNumbersChange action`() { fun `in Passcode_Password state, toggling the use numbers toggle should send ToggleNumbersChange action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("0-9") composeTestRule.onNodeWithText("0-9")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -373,10 +349,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, toggling the use special characters toggle should send ToggleSpecialCharactersChange action`() { fun `in Passcode_Password state, toggling the use special characters toggle should send ToggleSpecialCharactersChange action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("!@#$%^&*") composeTestRule.onNodeWithText("!@#$%^&*")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -414,10 +386,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, 1") composeTestRule.onNodeWithContentDescription("Minimum numbers, 1")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("\u2212")) .filterToOne(hasContentDescription("\u2212"))
@ -452,10 +420,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, 1") composeTestRule.onNodeWithContentDescription("Minimum numbers, 1")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("+")) .filterToOne(hasContentDescription("+"))
@ -490,10 +454,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers") composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("\u2212")) .filterToOne(hasContentDescription("\u2212"))
@ -522,10 +482,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers") composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("+")) .filterToOne(hasContentDescription("+"))
@ -554,10 +510,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, 1") composeTestRule.onNodeWithContentDescription("Minimum special, 1")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("\u2212")) .filterToOne(hasContentDescription("\u2212"))
@ -592,10 +544,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, 1") composeTestRule.onNodeWithContentDescription("Minimum special, 1")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("+")) .filterToOne(hasContentDescription("+"))
@ -630,10 +578,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars") composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("\u2212")) .filterToOne(hasContentDescription("\u2212"))
@ -662,10 +606,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars") composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
.onChildren() .onChildren()
.filterToOne(hasContentDescription("+")) .filterToOne(hasContentDescription("+"))
@ -678,10 +618,6 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `in Passcode_Password state, toggling the use avoid ambiguous characters toggle should send ToggleSpecialCharactersChange action`() { fun `in Passcode_Password state, toggling the use avoid ambiguous characters toggle should send ToggleSpecialCharactersChange action`() {
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Avoid ambiguous characters") composeTestRule.onNodeWithText("Avoid ambiguous characters")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -723,10 +659,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Unicode for "minus" used for content description // Unicode for "minus" used for content description
composeTestRule composeTestRule
.onNodeWithContentDescription("Number of words, $initialNumWords") .onNodeWithContentDescription("Number of words, $initialNumWords")
@ -762,10 +694,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Unicode for "minus" used for content description // Unicode for "minus" used for content description
composeTestRule composeTestRule
.onNodeWithContentDescription("Number of words, $initialNumWords") .onNodeWithContentDescription("Number of words, $initialNumWords")
@ -794,10 +722,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Unicode for "minus" used for content description // Unicode for "minus" used for content description
composeTestRule composeTestRule
.onNodeWithContentDescription("Number of words, $initialNumWords") .onNodeWithContentDescription("Number of words, $initialNumWords")
@ -827,10 +751,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithContentDescription("Number of words, 3") .onNodeWithContentDescription("Number of words, 3")
.onChildren() .onChildren()
@ -865,10 +785,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithText("Capitalize") .onNodeWithText("Capitalize")
.performScrollTo() .performScrollTo()
@ -901,10 +817,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Include number") composeTestRule.onNodeWithText("Include number")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -936,10 +848,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule composeTestRule
.onNodeWithText("Word separator") .onNodeWithText("Word separator")
.performScrollTo() .performScrollTo()
@ -972,10 +880,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
val newEmail = "test@example.com" val newEmail = "test@example.com"
// Find the text field for PlusAddressedEmail and input text // Find the text field for PlusAddressedEmail and input text
@ -1011,10 +915,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
val newDomain = "test.com" val newDomain = "test.com"
// Find the text field for Catch-All Email and input text // Find the text field for Catch-All Email and input text
@ -1048,10 +948,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Capitalize") composeTestRule.onNodeWithText("Capitalize")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
@ -1077,10 +973,6 @@ class GeneratorScreenTest : BaseComposeTest() {
), ),
) )
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Include number") composeTestRule.onNodeWithText("Include number")
.performScrollTo() .performScrollTo()
.performClick() .performClick()

View file

@ -0,0 +1,130 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class PasswordHistoryScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<PasswordHistoryEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(
PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
)
private val viewModel = mockk<PasswordHistoryViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
PasswordHistoryScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
)
}
}
@Test
fun `Empty state should display no passwords message`() {
updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Empty))
composeTestRule.onNodeWithText("No passwords to list.").assertIsDisplayed()
}
@Test
fun `Error state should display error message`() {
val errorMessage = "Error occurred"
updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage)))
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
}
@Test
fun `navigation icon click should trigger navigate back`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(
PasswordHistoryAction.CloseClick,
)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(PasswordHistoryEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `clicking the Copy button should send PasswordCopyClick action`() {
val password = PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date")
updateState(
PasswordHistoryState(
PasswordHistoryState.ViewState.Content(
passwords = listOf(password),
),
),
)
composeTestRule.onNodeWithText(password.password).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Copy").performClick()
verify {
viewModel.trySendAction(
PasswordHistoryAction.PasswordCopyClick(
PasswordHistoryState.GeneratedPassword("Password", "Date"),
),
)
}
}
@Test
fun `clicking the Clear button in the overflow menu should send PasswordClearClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onNodeWithText("Clear")
.performClick()
verify {
viewModel.trySendAction(PasswordHistoryAction.PasswordClearClick)
}
}
@Test
fun `Content state should display list of passwords`() {
val passwords =
listOf(PasswordHistoryState.GeneratedPassword(password = "Password1", date = "Date1"))
updateState(
PasswordHistoryState(
PasswordHistoryState.ViewState.Content(
passwords = passwords,
),
),
)
composeTestRule.onNodeWithText("Password1").assertIsDisplayed()
}
private fun updateState(state: PasswordHistoryState) {
mutableStateFlow.value = state
}
}

View file

@ -0,0 +1,66 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class PasswordHistoryViewModelTest : BaseViewModelTest() {
private val initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading)
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
}
}
@Test
fun `CloseClick action should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(PasswordHistoryAction.CloseClick)
assertEquals(PasswordHistoryEvent.NavigateBack, awaitItem())
}
}
@Test
fun `PasswordCopyClick action should emit password copied ShowToast event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
PasswordHistoryAction.PasswordCopyClick(
PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date"),
),
)
assertEquals(PasswordHistoryEvent.ShowToast("Not yet implemented."), awaitItem())
}
}
@Test
fun `PasswordClearClick action should emit password history cleared ShowToast event`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick)
assertEquals(
PasswordHistoryEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
//region Helper Functions
private fun createViewModel(): PasswordHistoryViewModel {
return PasswordHistoryViewModel()
}
//endregion Helper Functions
}