mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-710: Implement Password History UI (#352)
This commit is contained in:
parent
40fa3071ae
commit
d0205b4b59
14 changed files with 774 additions and 133 deletions
|
@ -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.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
|
||||
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.newSendDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.additem.navigateToVaultAddEditItem
|
||||
|
@ -46,6 +48,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
|||
},
|
||||
onNavigateToNewSend = { navController.navigateToNewSend() },
|
||||
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
|
||||
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
|
||||
)
|
||||
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
|
||||
vaultAddEditItemDestination(onNavigateBack = { navController.popBackStack() })
|
||||
|
@ -57,5 +60,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
|||
)
|
||||
vaultEditItemDestination(onNavigateBack = { navController.popBackStack() })
|
||||
newSendDestination(onNavigateBack = { navController.popBackStack() })
|
||||
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
|
|||
/**
|
||||
* Add vault unlocked destination to the root nav graph.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToVaultAddItem: () -> Unit,
|
||||
onNavigateToVaultItem: (vaultItemId: String) -> Unit,
|
||||
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
|
||||
onNavigateToNewSend: () -> Unit,
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
) {
|
||||
composable(
|
||||
route = VAULT_UNLOCKED_NAV_BAR_ROUTE,
|
||||
|
@ -41,6 +43,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
|||
onNavigateToVaultEditItem = onNavigateToVaultEditItem,
|
||||
onNavigateToNewSend = onNavigateToNewSend,
|
||||
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,6 +71,7 @@ fun VaultUnlockedNavBarScreen(
|
|||
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
|
||||
onNavigateToNewSend: () -> Unit,
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
navController.apply {
|
||||
|
@ -101,6 +102,7 @@ fun VaultUnlockedNavBarScreen(
|
|||
navigateToVaultAddItem = onNavigateToVaultAddItem,
|
||||
navigateToNewSend = onNavigateToNewSend,
|
||||
navigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
navigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
generatorTabClickedAction = {
|
||||
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
|
||||
},
|
||||
|
@ -132,6 +134,7 @@ private fun VaultUnlockedNavBarScaffold(
|
|||
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
|
||||
navigateToNewSend: () -> Unit,
|
||||
navigateToDeleteAccount: () -> Unit,
|
||||
navigateToPasswordHistory: () -> Unit,
|
||||
) {
|
||||
var shouldDimNavBar by remember { mutableStateOf(false) }
|
||||
|
||||
|
@ -191,7 +194,9 @@ private fun VaultUnlockedNavBarScaffold(
|
|||
},
|
||||
)
|
||||
sendGraph(onNavigateToNewSend = navigateToNewSend)
|
||||
generatorDestination()
|
||||
generatorDestination(
|
||||
onNavigateToPasswordHistory = { navigateToPasswordHistory() },
|
||||
)
|
||||
settingsGraph(
|
||||
navController = navController,
|
||||
onNavigateToDeleteAccount = navigateToDeleteAccount,
|
||||
|
|
|
@ -20,8 +20,12 @@ fun NavController.navigateToGenerator(navOptions: NavOptions? = null) {
|
|||
/**
|
||||
* Add generator destination to the root nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.generatorDestination() {
|
||||
fun NavGraphBuilder.generatorDestination(
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
) {
|
||||
composable(GENERATOR_ROUTE) {
|
||||
GeneratorScreen()
|
||||
GeneratorScreen(
|
||||
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.BitwardenTextField
|
||||
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.TooltipData
|
||||
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_LENGTH_SLIDER_MAX
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -80,6 +82,7 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
@Composable
|
||||
fun GeneratorScreen(
|
||||
viewModel: GeneratorViewModel = hiltViewModel(),
|
||||
onNavigateToPasswordHistory: () -> Unit,
|
||||
clipboardManager: ClipboardManager = LocalClipboardManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
@ -89,6 +92,8 @@ fun GeneratorScreen(
|
|||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
GeneratorEvent.NavigateToPasswordHistory -> onNavigateToPasswordHistory()
|
||||
|
||||
GeneratorEvent.CopyTextToClipboard -> {
|
||||
clipboardManager.setText(AnnotatedString(state.generatedText))
|
||||
}
|
||||
|
@ -165,7 +170,20 @@ fun GeneratorScreen(
|
|||
title = stringResource(id = R.string.generator),
|
||||
scrollBehavior = scrollBehavior,
|
||||
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
|
||||
private fun GeneratorPreview() {
|
||||
BitwardenTheme {
|
||||
GeneratorScreen()
|
||||
GeneratorScreen(
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,6 +67,10 @@ class GeneratorViewModel @Inject constructor(
|
|||
@Suppress("MaxLineLength")
|
||||
override fun handleAction(action: GeneratorAction) {
|
||||
when (action) {
|
||||
is GeneratorAction.PasswordHistoryClick -> {
|
||||
handlePasswordHistoryClick()
|
||||
}
|
||||
|
||||
is GeneratorAction.RegenerateClick -> {
|
||||
handleRegenerationClick()
|
||||
}
|
||||
|
@ -119,6 +123,14 @@ class GeneratorViewModel @Inject constructor(
|
|||
|
||||
//endregion Initialization and Overrides
|
||||
|
||||
//region Top Level Handlers
|
||||
|
||||
private fun handlePasswordHistoryClick() {
|
||||
sendEvent(GeneratorEvent.NavigateToPasswordHistory)
|
||||
}
|
||||
|
||||
//endregion Top Level Handlers
|
||||
|
||||
//region Generation Handlers
|
||||
|
||||
private fun loadPasscodeOptions(selectedType: Passcode) {
|
||||
|
@ -1105,6 +1117,11 @@ data class GeneratorState(
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
@ -1375,6 +1392,11 @@ sealed class GeneratorAction {
|
|||
*/
|
||||
sealed class GeneratorEvent {
|
||||
|
||||
/**
|
||||
* Navigates to the Password History screen.
|
||||
*/
|
||||
data object NavigateToPasswordHistory : GeneratorEvent()
|
||||
|
||||
/**
|
||||
* Copies text to the clipboard.
|
||||
*/
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -37,6 +37,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("My vault").performClick()
|
||||
|
@ -62,6 +63,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
|
||||
|
@ -88,6 +90,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Send").performClick()
|
||||
|
@ -113,6 +116,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
|
||||
|
@ -139,6 +143,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Generator").performClick()
|
||||
|
@ -164,6 +169,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
|
||||
|
@ -190,6 +196,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Settings").performClick()
|
||||
|
@ -215,6 +222,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToVaultEditItem = {},
|
||||
onNavigateToNewSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
|
||||
|
|
|
@ -31,10 +31,14 @@ 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
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class GeneratorScreenTest : BaseComposeTest() {
|
||||
private var onNavigateToPasswordHistoryScreenCalled = false
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
GeneratorState(
|
||||
generatedText = "Placeholder",
|
||||
|
@ -59,12 +63,24 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
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
|
||||
fun `Snackbar should be displayed with correct message on ShowSnackbar event`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
mutableEventFlow.tryEmit(GeneratorEvent.ShowSnackbar("Test Snackbar Message".asText()))
|
||||
|
||||
composeTestRule
|
||||
|
@ -74,10 +90,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `clicking the Regenerate button should send RegenerateClick action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "Generate password")
|
||||
.performClick()
|
||||
|
@ -89,10 +101,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `clicking the Copy button should send CopyClick action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "Copy")
|
||||
.performClick()
|
||||
|
@ -104,10 +112,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `clicking a MainStateOption should send MainTypeOptionSelect action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Opens the menu
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "What would you like to generate?, Password")
|
||||
|
@ -135,10 +139,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `clicking a PasscodeOption should send PasscodeTypeOption action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Opens the menu
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "Password type, Password")
|
||||
|
@ -178,10 +178,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Opens the menu
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "Username type, Plus addressed email")
|
||||
|
@ -212,10 +208,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `in Passcode_Password state, the ViewModel state should update the UI correctly`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "What would you like to generate?, Password")
|
||||
.assertIsDisplayed()
|
||||
|
@ -275,10 +267,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in Passcode_Password state, adjusting the slider should send SliderLengthChange action with length not equal to default`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Length")
|
||||
.onSiblings()
|
||||
|
@ -308,10 +296,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in Passcode_Password state, toggling the capital letters toggle should send ToggleCapitalLettersChange action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("A—Z")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -328,10 +312,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in Passcode_Password state, toggling the use lowercase toggle should send ToggleLowercaseLettersChange action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("a—z")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -353,10 +333,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in Passcode_Password state, toggling the use numbers toggle should send ToggleNumbersChange action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("0-9")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -373,10 +349,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in Passcode_Password state, toggling the use special characters toggle should send ToggleSpecialCharactersChange action`() {
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("!@#$%^&*")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -414,10 +386,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum numbers, 1")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("\u2212"))
|
||||
|
@ -452,10 +420,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum numbers, 1")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("+"))
|
||||
|
@ -490,10 +454,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("\u2212"))
|
||||
|
@ -522,10 +482,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("+"))
|
||||
|
@ -554,10 +510,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum special, 1")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("\u2212"))
|
||||
|
@ -592,10 +544,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum special, 1")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("+"))
|
||||
|
@ -630,10 +578,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("\u2212"))
|
||||
|
@ -662,10 +606,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("+"))
|
||||
|
@ -678,10 +618,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
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")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -723,10 +659,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Unicode for "minus" used for content description
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Number of words, $initialNumWords")
|
||||
|
@ -762,10 +694,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Unicode for "minus" used for content description
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Number of words, $initialNumWords")
|
||||
|
@ -794,10 +722,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Unicode for "minus" used for content description
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Number of words, $initialNumWords")
|
||||
|
@ -827,10 +751,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Number of words, 3")
|
||||
.onChildren()
|
||||
|
@ -865,10 +785,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Capitalize")
|
||||
.performScrollTo()
|
||||
|
@ -901,10 +817,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Include number")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -936,10 +848,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Word separator")
|
||||
.performScrollTo()
|
||||
|
@ -972,10 +880,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
val newEmail = "test@example.com"
|
||||
|
||||
// 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"
|
||||
|
||||
// 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")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
@ -1077,10 +973,6 @@ class GeneratorScreenTest : BaseComposeTest() {
|
|||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
GeneratorScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Include number")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue