Create initial vault item shell (#253)

This commit is contained in:
David Perez 2023-11-16 18:00:14 -06:00 committed by Álison Fernandes
parent f912eb14ef
commit dd6e7639b5
13 changed files with 332 additions and 0 deletions

View file

@ -10,6 +10,8 @@ import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
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.vault.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.vault.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVaultAddItem
import com.x8bit.bitwarden.ui.vault.feature.vault.vaultAddItemDestination
@ -34,11 +36,13 @@ fun NavGraphBuilder.vaultUnlockedGraph(
) {
vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem = { navController.navigateToVaultAddItem() },
onNavigateToVaultItem = { navController.navigateToVaultItem(it) },
onNavigateToNewSend = { navController.navigateToNewSend() },
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
)
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
vaultAddItemDestination(onNavigateBack = { navController.popBackStack() })
vaultItemDestination(onNavigateBack = { navController.popBackStack() })
newSendDestination(onNavigateBack = { navController.popBackStack() })
}
}

View file

@ -23,6 +23,7 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
*/
fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem: () -> Unit,
onNavigateToVaultItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
) {
@ -35,6 +36,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
) {
VaultUnlockedNavBarScreen(
onNavigateToVaultAddItem = onNavigateToVaultAddItem,
onNavigateToVaultItem = onNavigateToVaultItem,
onNavigateToNewSend = onNavigateToNewSend,
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
)

View file

@ -66,6 +66,7 @@ fun VaultUnlockedNavBarScreen(
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
onNavigateToVaultAddItem: () -> Unit,
onNavigateToVaultItem: (vaultItemId: String) -> Unit,
onNavigateToNewSend: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
) {
@ -93,6 +94,7 @@ fun VaultUnlockedNavBarScreen(
}
VaultUnlockedNavBarScaffold(
navController = navController,
onNavigateToVaultItem = onNavigateToVaultItem,
navigateToVaultAddItem = onNavigateToVaultAddItem,
navigateToNewSend = onNavigateToNewSend,
navigateToDeleteAccount = onNavigateToDeleteAccount,
@ -123,6 +125,7 @@ private fun VaultUnlockedNavBarScaffold(
generatorTabClickedAction: () -> Unit,
settingsTabClickedAction: () -> Unit,
navigateToVaultAddItem: () -> Unit,
onNavigateToVaultItem: (vaultItemId: String) -> Unit,
navigateToNewSend: () -> Unit,
navigateToDeleteAccount: () -> Unit,
) {
@ -178,6 +181,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToVaultAddItemScreen = {
navigateToVaultAddItem()
},
onNavigateToVaultItemScreen = onNavigateToVaultItem,
onDimBottomNavBarRequest = { shouldDim ->
shouldDimNavBar = shouldDim
},

View file

@ -12,11 +12,13 @@ const val VAULT_ROUTE: String = "vault"
*/
fun NavGraphBuilder.vaultDestination(
onNavigateToVaultAddItemScreen: () -> Unit,
onNavigateToVaultItemScreen: (vaultItemId: String) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
) {
composable(VAULT_ROUTE) {
VaultScreen(
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
onNavigateToVaultItemScreen = onNavigateToVaultItemScreen,
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
)
}

View file

@ -45,6 +45,7 @@ import kotlinx.collections.immutable.toImmutableList
fun VaultScreen(
viewModel: VaultViewModel = hiltViewModel(),
onNavigateToVaultAddItemScreen: () -> Unit,
onNavigateToVaultItemScreen: (vaultItemId: String) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
) {
val context = LocalContext.current
@ -52,6 +53,8 @@ fun VaultScreen(
when (event) {
VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
is VaultEvent.NavigateToItemScreen -> onNavigateToVaultItemScreen(event.vaultItemId)
VaultEvent.NavigateToVaultSearchScreen -> {
// TODO Create vault search screen and navigation implementation BIT-213
Toast

View file

@ -153,6 +153,7 @@ class VaultViewModel @Inject constructor(
is DataState.Pending -> vaultPendingReceive(vaultData = vaultData)
}
}
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
// TODO update state to error state BIT-1157
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
@ -407,6 +408,13 @@ sealed class VaultEvent {
*/
data object NavigateToAddItemScreen : VaultEvent()
/**
* Navigate to the Vault Item screen.
*/
data class NavigateToItemScreen(
val vaultItemId: String,
) : VaultEvent()
/**
* Navigate to the item details screen.
*/

View file

@ -0,0 +1,53 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.item
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val VAULT_ITEM_PREFIX = "vault_item"
private const val VAULT_ITEM_ID = "vault_item_id"
private const val VAULT_ITEM_ROUTE = "$VAULT_ITEM_PREFIX/{$VAULT_ITEM_ID}"
/**
* Class to retrieve vault item arguments from the [SavedStateHandle].
*/
class VaultItemArgs(val vaultItemId: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[VAULT_ITEM_ID]) as String,
)
}
/**
* Add the vault item screen to the nav graph.
*/
fun NavGraphBuilder.vaultItemDestination(
onNavigateBack: () -> Unit,
) {
composable(
route = VAULT_ITEM_ROUTE,
arguments = listOf(
navArgument(VAULT_ITEM_ID) { type = NavType.StringType },
),
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
VaultItemScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the vault item screen.
*/
fun NavController.navigateToVaultItem(
vaultItemId: String,
navOptions: NavOptions? = null,
) {
navigate("$VAULT_ITEM_PREFIX/$vaultItemId", navOptions)
}

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
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.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.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/**
* Displays the vault item screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VaultItemScreen(
viewModel: VaultItemViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultItemEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.view_item),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.CloseClick) }
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.imePadding()
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View file

@ -0,0 +1,68 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.item
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the vault item screen
*/
@HiltViewModel
class VaultItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
vaultItemId = VaultItemArgs(savedStateHandle).vaultItemId,
),
) {
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemAction) {
when (action) {
VaultItemAction.CloseClick -> handleCloseClick()
}
}
private fun handleCloseClick() {
sendEvent(VaultItemEvent.NavigateBack)
}
}
/**
* Represents the state for viewing an item in the vault.
*/
@Parcelize
data class VaultItemState(
val vaultItemId: String,
) : Parcelable
/**
* Represents a set of events related view a vault item.
*/
sealed class VaultItemEvent {
/**
* Navigates back.
*/
data object NavigateBack : VaultItemEvent()
}
/**
* Represents a set of actions related view a vault item.
*/
sealed class VaultItemAction {
/**
* The user has clicked the close button.
*/
data object CloseClick : VaultItemAction()
}

View file

@ -33,6 +33,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -56,6 +57,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -80,6 +82,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -103,6 +106,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -127,6 +131,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -150,6 +155,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -174,6 +180,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)
@ -197,6 +204,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToVaultItem = {},
onNavigateToNewSend = {},
onNavigateToDeleteAccount = {},
)

View file

@ -28,6 +28,7 @@ import org.junit.Test
class VaultScreenTest : BaseComposeTest() {
private var onNavigateToVaultAddItemScreenCalled = false
private var onNavigateToVaultItemScreenCalled = false
private var onDimBottomNavBarRequestCalled = false
private val mutableEventFlow = MutableSharedFlow<VaultEvent>(
@ -45,6 +46,7 @@ class VaultScreenTest : BaseComposeTest() {
VaultScreen(
viewModel = viewModel,
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
onNavigateToVaultItemScreen = { onNavigateToVaultItemScreenCalled = true },
onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true },
)
}
@ -115,6 +117,12 @@ class VaultScreenTest : BaseComposeTest() {
assertTrue(onNavigateToVaultAddItemScreenCalled)
}
@Test
fun `NavigateToItemScreen event should call onNavigateToVaultItemScreenCalled`() {
mutableEventFlow.tryEmit(VaultEvent.NavigateToItemScreen(vaultItemId = "id"))
assertTrue(onNavigateToVaultItemScreenCalled)
}
@Test
fun `clicking a favorite item should send VaultItemClick with the correct item`() {
val itemText = "Test Item"

View file

@ -0,0 +1,51 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.item
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.Before
import org.junit.Test
class VaultItemScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<VaultItemEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultItemViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VaultItemScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
)
}
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription(label = "Close").performClick()
verify {
viewModel.trySendAction(VaultItemAction.CloseClick)
}
}
}
private const val VAULT_ITEM_ID = "vault_item_id"
private val DEFAULT_STATE: VaultItemState = VaultItemState(
vaultItemId = VAULT_ITEM_ID,
)

View file

@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.item
import androidx.lifecycle.SavedStateHandle
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 VaultItemViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(vaultItemId = "something_different")
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.CloseClick)
assertEquals(VaultItemEvent.NavigateBack, awaitItem())
}
}
private fun createViewModel(
state: VaultItemState? = DEFAULT_STATE,
vaultItemId: String = VAULT_ITEM_ID,
): VaultItemViewModel = VaultItemViewModel(
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("vault_item_id", vaultItemId)
},
)
}
private const val VAULT_ITEM_ID = "vault_item_id"
private val DEFAULT_STATE: VaultItemState = VaultItemState(
vaultItemId = VAULT_ITEM_ID,
)