BIT-457: Add Vault Settings and Folders screen UI (#457)

This commit is contained in:
Caleb Derosier 2023-12-29 14:11:41 -06:00 committed by Álison Fernandes
parent 78461394f3
commit c94b303abc
15 changed files with 530 additions and 164 deletions

View file

@ -27,6 +27,7 @@ private const val SETTINGS_ROUTE: String = "settings"
fun NavGraphBuilder.settingsGraph( fun NavGraphBuilder.settingsGraph(
navController: NavController, navController: NavController,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit,
) { ) {
navigation( navigation(
startDestination = SETTINGS_ROUTE, startDestination = SETTINGS_ROUTE,
@ -52,12 +53,15 @@ fun NavGraphBuilder.settingsGraph(
appearanceDestination(onNavigateBack = { navController.popBackStack() }) appearanceDestination(onNavigateBack = { navController.popBackStack() })
autoFillDestination(onNavigateBack = { navController.popBackStack() }) autoFillDestination(onNavigateBack = { navController.popBackStack() })
otherDestination(onNavigateBack = { navController.popBackStack() }) otherDestination(onNavigateBack = { navController.popBackStack() })
vaultSettingsDestination(onNavigateBack = { navController.popBackStack() }) vaultSettingsDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToFolders = onNavigateToFolders,
)
} }
} }
/** /**
* Navigate to the settings screen screen. * Navigate to the settings screen.
*/ */
fun NavController.navigateToSettingsGraph(navOptions: NavOptions? = null) { fun NavController.navigateToSettingsGraph(navOptions: NavOptions? = null) {
navigate(SETTINGS_GRAPH_ROUTE, navOptions) navigate(SETTINGS_GRAPH_ROUTE, navOptions)

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val FOLDERS_ROUTE = "settings_folders"
/**
* Add folders destinations to the nav graph.
*/
fun NavGraphBuilder.foldersDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = FOLDERS_ROUTE,
) {
FoldersScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the folders screen.
*/
fun NavController.navigateToFolders(navOptions: NavOptions? = null) {
navigate(FOLDERS_ROUTE, navOptions)
}

View file

@ -0,0 +1,108 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
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.remember
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.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/**
* Displays the folders screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FoldersScreen(
onNavigateBack: () -> Unit,
viewModel: FoldersViewModel = hiltViewModel(),
) {
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
FoldersEvent.NavigateBack -> onNavigateBack()
is FoldersEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.folders),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(FoldersAction.CloseButtonClick) }
},
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(FoldersAction.AddFolderButtonClick) }
},
modifier = Modifier.navigationBarsPadding(),
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus),
contentDescription = stringResource(id = R.string.add_item),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
) {
// TODO BIT-460 populate Folders screen
Text(
text = stringResource(id = R.string.no_folders_to_list),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxSize()
.padding(
vertical = 4.dp,
horizontal = 16.dp,
),
)
}
}
}

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* View model for the folders screen.
*/
@HiltViewModel
class FoldersViewModel @Inject constructor() :
BaseViewModel<Unit, FoldersEvent, FoldersAction>(
initialState = Unit,
) {
override fun handleAction(action: FoldersAction): Unit = when (action) {
FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked()
FoldersAction.CloseButtonClick -> handleCloseButtonClicked()
}
private fun handleAddFolderButtonClicked() {
// TODO BIT-458 implement add folders
sendEvent(FoldersEvent.ShowToast("Not yet implemented."))
}
private fun handleCloseButtonClicked() {
sendEvent(FoldersEvent.NavigateBack)
}
}
/**
* Models events for the folders screen.
*/
sealed class FoldersEvent {
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : FoldersEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(
val message: String,
) : FoldersEvent()
}
/**
* Models actions for the folders screen.
*/
sealed class FoldersAction {
/**
* Indicates that the user clicked the add folder button.
*/
data object AddFolderButtonClick : FoldersAction()
/**
* Indicates that the user clicked the close button.
*/
data object CloseButtonClick : FoldersAction()
}

View file

@ -12,11 +12,15 @@ private const val VAULT_SETTINGS_ROUTE = "vault_settings"
*/ */
fun NavGraphBuilder.vaultSettingsDestination( fun NavGraphBuilder.vaultSettingsDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToFolders: () -> Unit,
) { ) {
composableWithPushTransitions( composableWithPushTransitions(
route = VAULT_SETTINGS_ROUTE, route = VAULT_SETTINGS_ROUTE,
) { ) {
VaultSettingsScreen(onNavigateBack = onNavigateBack) VaultSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToFolders = onNavigateToFolders,
)
} }
} }

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import android.widget.Toast
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -12,26 +14,36 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/** /**
* Displays the vault screen. * Displays the vault settings screen.
*/ */
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VaultSettingsScreen( fun VaultSettingsScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToFolders: () -> Unit,
viewModel: VaultSettingsViewModel = hiltViewModel(), viewModel: VaultSettingsViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
VaultSettingsEvent.NavigateBack -> onNavigateBack.invoke() VaultSettingsEvent.NavigateBack -> onNavigateBack()
VaultSettingsEvent.NavigateToFolders -> onNavigateToFolders()
is VaultSettingsEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
} }
} }
@ -58,7 +70,34 @@ fun VaultSettingsScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
// TODO: BIT-928 Display Vault UI BitwardenTextRow(
text = stringResource(R.string.folders),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultSettingsAction.FoldersButtonClick) }
},
withDivider = true,
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextRow(
text = stringResource(R.string.export_vault),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultSettingsAction.ExportVaultClick) }
},
withDivider = true,
modifier = Modifier.fillMaxWidth(),
)
BitwardenExternalLinkRow(
text = stringResource(R.string.import_items),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(VaultSettingsAction.ImportItemsClick) }
},
withDivider = true,
dialogTitle = stringResource(id = R.string.import_items_confirmation),
dialogMessage = stringResource(id = R.string.import_items_description),
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }

View file

@ -13,7 +13,28 @@ class VaultSettingsViewModel @Inject constructor() :
initialState = Unit, initialState = Unit,
) { ) {
override fun handleAction(action: VaultSettingsAction): Unit = when (action) { override fun handleAction(action: VaultSettingsAction): Unit = when (action) {
VaultSettingsAction.BackClick -> sendEvent(VaultSettingsEvent.NavigateBack) VaultSettingsAction.BackClick -> handleBackClicked()
VaultSettingsAction.ExportVaultClick -> handleExportVaultClicked()
VaultSettingsAction.FoldersButtonClick -> handleFoldersButtonClicked()
VaultSettingsAction.ImportItemsClick -> handleImportItemsClicked()
}
private fun handleBackClicked() {
sendEvent(VaultSettingsEvent.NavigateBack)
}
private fun handleExportVaultClicked() {
// TODO BIT-1272 go to vault export screen
sendEvent(VaultSettingsEvent.ShowToast("Not yet implemented."))
}
private fun handleFoldersButtonClicked() {
sendEvent(VaultSettingsEvent.NavigateToFolders)
}
private fun handleImportItemsClicked() {
// TODO BIT-972 implement import items functionality
sendEvent(VaultSettingsEvent.ShowToast("Not yet implemented."))
} }
} }
@ -25,6 +46,18 @@ sealed class VaultSettingsEvent {
* Navigate back. * Navigate back.
*/ */
data object NavigateBack : VaultSettingsEvent() data object NavigateBack : VaultSettingsEvent()
/**
* Navigate to the Folders screen.
*/
data object NavigateToFolders : VaultSettingsEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(
val message: String,
) : VaultSettingsEvent()
} }
/** /**
@ -35,4 +68,19 @@ sealed class VaultSettingsAction {
* User clicked back button. * User clicked back button.
*/ */
data object BackClick : VaultSettingsAction() data object BackClick : VaultSettingsAction()
/**
* Indicates that the user clicked the Export Vault button.
*/
data object ExportVaultClick : VaultSettingsAction()
/**
* Indicates that the user clicked the Folders button.
*/
data object FoldersButtonClick : VaultSettingsAction()
/**
* Indicates that the user clicked the Import Items button.
*/
data object ImportItemsClick : VaultSettingsAction()
} }

View file

@ -6,6 +6,8 @@ import androidx.navigation.NavOptions
import androidx.navigation.navigation import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
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.settings.folders.foldersDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders
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.navigateToPasswordHistory
@ -38,6 +40,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
route = VAULT_UNLOCKED_GRAPH_ROUTE, route = VAULT_UNLOCKED_GRAPH_ROUTE,
) { ) {
vaultUnlockedNavBarDestination( vaultUnlockedNavBarDestination(
onNavigateToFolders = { navController.navigateToFolders() },
onNavigateToVaultAddItem = { onNavigateToVaultAddItem = {
navController.navigateToVaultAddEditItem(VaultAddEditType.AddItem) navController.navigateToVaultAddEditItem(VaultAddEditType.AddItem)
}, },
@ -59,5 +62,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
) )
newSendDestination(onNavigateBack = { navController.popBackStack() }) newSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() }) passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
foldersDestination(onNavigateBack = { navController.popBackStack() })
} }
} }

View file

@ -27,6 +27,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit, onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
) { ) {
composableWithStayTransitions( composableWithStayTransitions(
@ -38,6 +39,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultEditItem = onNavigateToVaultEditItem, onNavigateToVaultEditItem = onNavigateToVaultEditItem,
onNavigateToNewSend = onNavigateToNewSend, onNavigateToNewSend = onNavigateToNewSend,
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToFolders = onNavigateToFolders,
onNavigateToPasswordHistory = onNavigateToPasswordHistory, onNavigateToPasswordHistory = onNavigateToPasswordHistory,
) )
} }

View file

@ -62,6 +62,7 @@ import kotlinx.parcelize.Parcelize
/** /**
* Top level composable for the Vault Unlocked Screen. * Top level composable for the Vault Unlocked Screen.
*/ */
@Suppress("LongParameterList")
@Composable @Composable
fun VaultUnlockedNavBarScreen( fun VaultUnlockedNavBarScreen(
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(), viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
@ -71,6 +72,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit, onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
) { ) {
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
@ -102,6 +104,7 @@ fun VaultUnlockedNavBarScreen(
navigateToVaultAddItem = onNavigateToVaultAddItem, navigateToVaultAddItem = onNavigateToVaultAddItem,
navigateToNewSend = onNavigateToNewSend, navigateToNewSend = onNavigateToNewSend,
navigateToDeleteAccount = onNavigateToDeleteAccount, navigateToDeleteAccount = onNavigateToDeleteAccount,
navigateToFolders = onNavigateToFolders,
navigateToPasswordHistory = onNavigateToPasswordHistory, navigateToPasswordHistory = onNavigateToPasswordHistory,
generatorTabClickedAction = { generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
@ -134,6 +137,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
navigateToNewSend: () -> Unit, navigateToNewSend: () -> Unit,
navigateToDeleteAccount: () -> Unit, navigateToDeleteAccount: () -> Unit,
navigateToFolders: () -> Unit,
navigateToPasswordHistory: () -> Unit, navigateToPasswordHistory: () -> Unit,
) { ) {
var shouldDimNavBar by remember { mutableStateOf(false) } var shouldDimNavBar by remember { mutableStateOf(false) }
@ -200,6 +204,7 @@ private fun VaultUnlockedNavBarScaffold(
settingsGraph( settingsGraph(
navController = navController, navController = navController,
onNavigateToDeleteAccount = navigateToDeleteAccount, onNavigateToDeleteAccount = navigateToDeleteAccount,
onNavigateToFolders = navigateToFolders,
) )
} }
} }

View file

@ -0,0 +1,56 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import androidx.compose.ui.test.onNodeWithContentDescription
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class FoldersScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<FoldersEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(Unit)
val viewModel = mockk<FoldersViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
FoldersScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
)
}
}
@Test
fun `close button click should send CloseButtonClick`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(FoldersAction.CloseButtonClick) }
}
@Test
fun `add folder button click should send AddFolderButtonClick`() {
composeTestRule.onNodeWithContentDescription("Add item").performClick()
verify {
viewModel.trySendAction(FoldersAction.AddFolderButtonClick)
}
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(FoldersEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
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 FoldersViewModelTest : BaseViewModelTest() {
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FoldersAction.CloseButtonClick)
assertEquals(FoldersEvent.NavigateBack, awaitItem())
}
}
@Test
fun `AddFolderButtonClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FoldersAction.AddFolderButtonClick)
assertEquals(
FoldersEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
private fun createViewModel(): FoldersViewModel = FoldersViewModel()
}

View file

@ -1,46 +1,98 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.vault package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test import org.junit.Test
class VaultSettingsScreenTest : BaseComposeTest() { class VaultSettingsScreenTest : BaseComposeTest() {
@Test private var onNavigateBackCalled = false
fun `on back click should send BackClick`() { private var onNavigateToFoldersCalled = false
val viewModel: VaultSettingsViewModel = mockk { private val mutableEventFlow = MutableSharedFlow<VaultSettingsEvent>(
every { eventFlow } returns emptyFlow() extraBufferCapacity = Int.MAX_VALUE,
every { trySendAction(VaultSettingsAction.BackClick) } returns Unit )
} private val mutableStateFlow = MutableStateFlow(Unit)
val viewModel = mockk<VaultSettingsViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent { composeTestRule.setContent {
VaultSettingsScreen( VaultSettingsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToFolders = { onNavigateToFoldersCalled = true },
) )
} }
}
@Test
fun `on back click should send BackClick`() {
every { viewModel.trySendAction(VaultSettingsAction.BackClick) } returns Unit
composeTestRule.onNodeWithContentDescription("Back").performClick() composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(VaultSettingsAction.BackClick) } verify { viewModel.trySendAction(VaultSettingsAction.BackClick) }
} }
@Test @Test
fun `on NavigateAbout should call onNavigateToVault`() { fun `export vault click should send ExportVaultClick`() {
var haveCalledNavigateBack = false composeTestRule.onNodeWithText("Export vault").performClick()
val viewModel = mockk<VaultSettingsViewModel> { verify {
every { eventFlow } returns flowOf(VaultSettingsEvent.NavigateBack) viewModel.trySendAction(VaultSettingsAction.ExportVaultClick)
} }
composeTestRule.setContent { }
VaultSettingsScreen(
viewModel = viewModel, @Test
onNavigateBack = { haveCalledNavigateBack = true }, fun `import items click should display dialog and confirming should send ImportItemsClick`() {
) composeTestRule.onNodeWithText("Import items").performClick()
composeTestRule
.onNodeWithText("Continue")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VaultSettingsAction.ImportItemsClick)
} }
assertTrue(haveCalledNavigateBack) }
@Test
fun `import items click should display dialog & canceling should not send ImportItemsClick`() {
composeTestRule.onNodeWithText("Import items").performClick()
composeTestRule
.onNodeWithText("Cancel")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(VaultSettingsAction.ImportItemsClick)
}
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(VaultSettingsEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToFolders should call onNavigateToFolders`() {
mutableEventFlow.tryEmit(VaultSettingsEvent.NavigateToFolders)
assertTrue(onNavigateToFoldersCalled)
} }
} }

View file

@ -9,11 +9,37 @@ import org.junit.jupiter.api.Test
class VaultSettingsViewModelTest : BaseViewModelTest() { class VaultSettingsViewModelTest : BaseViewModelTest() {
@Test @Test
fun `on BackClick should emit NavigateBack`() = runTest { fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = VaultSettingsViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(VaultSettingsAction.BackClick) viewModel.trySendAction(VaultSettingsAction.BackClick)
assertEquals(VaultSettingsEvent.NavigateBack, awaitItem()) assertEquals(VaultSettingsEvent.NavigateBack, awaitItem())
} }
} }
@Test
fun `ExportVaultClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultSettingsAction.ExportVaultClick)
assertEquals(
VaultSettingsEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
@Test
fun `ImportItemsClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultSettingsAction.ImportItemsClick)
assertEquals(
VaultSettingsEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
private fun createViewModel(): VaultSettingsViewModel = VaultSettingsViewModel()
} }

View file

@ -9,10 +9,20 @@ import io.mockk.every
import io.mockk.mockk 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 org.junit.Before
import org.junit.Test import org.junit.Test
class VaultUnlockedNavBarScreenTest : BaseComposeTest() { class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
private val fakeNavHostController = FakeNavHostController() private val fakeNavHostController = FakeNavHostController()
private val mutableEventFlow = MutableSharedFlow<VaultUnlockedNavBarEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(Unit)
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val expectedNavOptions = navOptions { private val expectedNavOptions = navOptions {
// When changing root navigation state, pop everything else off the back stack: // When changing root navigation state, pop everything else off the back stack:
@ -24,9 +34,8 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
restoreState = true restoreState = true
} }
@Test @Before
fun `vault tab click should send VaultTabClick action`() { fun setup() {
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true)
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
@ -37,90 +46,42 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToVaultEditItem = {}, onNavigateToVaultEditItem = {},
onNavigateToNewSend = {}, onNavigateToNewSend = {},
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToFolders = {},
onNavigateToPasswordHistory = {}, onNavigateToPasswordHistory = {},
) )
} }
onNodeWithText("My vault").performClick()
} }
}
@Test
fun `vault tab click should send VaultTabClick action`() {
composeTestRule.onNodeWithText("My vault").performClick()
verify { viewModel.trySendAction(VaultUnlockedNavBarAction.VaultTabClick) } verify { viewModel.trySendAction(VaultUnlockedNavBarAction.VaultTabClick) }
} }
@Test @Test
fun `NavigateToVaultScreen should navigate to VaultScreen`() { fun `NavigateToVaultScreen should navigate to VaultScreen`() {
val vaultUnlockedNavBarEventFlow = MutableSharedFlow<VaultUnlockedNavBarEvent>( composeTestRule.runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
extraBufferCapacity = Int.MAX_VALUE, mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToVaultScreen)
) composeTestRule.runOnIdle {
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) { fakeNavHostController.assertLastNavigation(
every { eventFlow } returns vaultUnlockedNavBarEventFlow route = "vault_graph",
} navOptions = expectedNavOptions,
composeTestRule.apply { )
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
vaultUnlockedNavBarEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToVaultScreen)
runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "vault_graph",
navOptions = expectedNavOptions,
)
}
} }
} }
@Test @Test
fun `send tab click should send SendTabClick action`() { fun `send tab click should send SendTabClick action`() {
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) composeTestRule.onNodeWithText("Send").performClick()
composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
onNodeWithText("Send").performClick()
}
verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SendTabClick) } verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SendTabClick) }
} }
@Test @Test
fun `NavigateToSendScreen should navigate to SendScreen`() { fun `NavigateToSendScreen should navigate to SendScreen`() {
val vaultUnlockedNavBarEventFlow = MutableSharedFlow<VaultUnlockedNavBarEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) {
every { eventFlow } returns vaultUnlockedNavBarEventFlow
}
composeTestRule.apply { composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
vaultUnlockedNavBarEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSendScreen) mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSendScreen)
runOnIdle { runOnIdle {
fakeNavHostController.assertLastNavigation( fakeNavHostController.assertLastNavigation(
route = "send", route = "send",
@ -132,48 +93,15 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
@Test @Test
fun `generator tab click should send GeneratorTabClick action`() { fun `generator tab click should send GeneratorTabClick action`() {
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) composeTestRule.onNodeWithText("Generator").performClick()
composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
onNodeWithText("Generator").performClick()
}
verify { viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) } verify { viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) }
} }
@Test @Test
fun `NavigateToGeneratorScreen should navigate to GeneratorScreen`() { fun `NavigateToGeneratorScreen should navigate to GeneratorScreen`() {
val vaultUnlockedNavBarEventFlow = MutableSharedFlow<VaultUnlockedNavBarEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) {
every { eventFlow } returns vaultUnlockedNavBarEventFlow
}
composeTestRule.apply { composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
vaultUnlockedNavBarEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen) mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen)
runOnIdle { runOnIdle {
fakeNavHostController.assertLastNavigation( fakeNavHostController.assertLastNavigation(
route = "generator", route = "generator",
@ -185,48 +113,15 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
@Test @Test
fun `settings tab click should send SendTabClick action`() { fun `settings tab click should send SendTabClick action`() {
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) composeTestRule.onNodeWithText("Settings").performClick()
composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
onNodeWithText("Settings").performClick()
}
verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SettingsTabClick) } verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SettingsTabClick) }
} }
@Test @Test
fun `NavigateToSettingsScreen should navigate to SettingsScreen`() { fun `NavigateToSettingsScreen should navigate to SettingsScreen`() {
val vaultUnlockedNavBarEventFlow = MutableSharedFlow<VaultUnlockedNavBarEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
val viewModel = mockk<VaultUnlockedNavBarViewModel>(relaxed = true) {
every { eventFlow } returns vaultUnlockedNavBarEventFlow
}
composeTestRule.apply { composeTestRule.apply {
setContent {
VaultUnlockedNavBarScreen(
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToVaultEditItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
onNavigateToPasswordHistory = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault_graph") }
vaultUnlockedNavBarEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSettingsScreen) mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSettingsScreen)
runOnIdle { runOnIdle {
fakeNavHostController.assertLastNavigation( fakeNavHostController.assertLastNavigation(
route = "settings_graph", route = "settings_graph",