From d51fa8f1ecc715bbcb778500fef9449d78da047a Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:58:20 -0700 Subject: [PATCH] Move to organization navigation (#620) --- .../vaultunlocked/VaultUnlockedNavigation.kt | 9 ++ .../vault/feature/item/VaultItemNavigation.kt | 2 + .../ui/vault/feature/item/VaultItemScreen.kt | 4 +- .../VaultMoveToOrganizationNavigation.kt | 56 +++++++++ .../VaultMoveToOrganizationScreen.kt | 97 +++++++++++++++ .../VaultMoveToOrganizationViewModel.kt | 110 ++++++++++++++++++ .../vault/feature/item/VaultItemScreenTest.kt | 9 ++ .../VaultMoveToOrganizationScreenTest.kt | 61 ++++++++++ .../VaultMoveToOrganizationViewModelTest.kt | 77 ++++++++++++ 9 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index f03fcd5e87..120ed1d899 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -21,6 +21,8 @@ import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMoveToOrganization +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType @@ -37,6 +39,7 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) { /** * Add vault unlocked destinations to the root nav graph. */ +@Suppress("LongMethod") fun NavGraphBuilder.vaultUnlockedGraph( navController: NavController, ) { @@ -68,11 +71,17 @@ fun NavGraphBuilder.vaultUnlockedGraph( }, onNavigateBack = { navController.popBackStack() }, ) + vaultMoveToOrganizationDestination( + onNavigateBack = { navController.popBackStack() }, + ) vaultItemDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToVaultEditItem = { navController.navigateToVaultAddEdit(VaultAddEditType.EditItem(it)) }, + onNavigateToMoveToOrganization = { + navController.navigateToVaultMoveToOrganization(it) + }, ) vaultQrCodeScanDestination( onNavigateToManualCodeEntryScreen = { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt index fd18da2d88..92f1a88fda 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt @@ -29,6 +29,7 @@ data class VaultItemArgs(val vaultItemId: String) { fun NavGraphBuilder.vaultItemDestination( onNavigateBack: () -> Unit, onNavigateToVaultEditItem: (vaultItemId: String) -> Unit, + onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit, ) { composableWithSlideTransitions( route = VAULT_ITEM_ROUTE, @@ -39,6 +40,7 @@ fun NavGraphBuilder.vaultItemDestination( VaultItemScreen( onNavigateBack = onNavigateBack, onNavigateToVaultAddEditItem = onNavigateToVaultEditItem, + onNavigateToMoveToOrganization = onNavigateToMoveToOrganization, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index fc4f16cc98..20713a8bae 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -56,6 +56,7 @@ fun VaultItemScreen( intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), onNavigateBack: () -> Unit, onNavigateToVaultAddEditItem: (vaultItemId: String) -> Unit, + onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -82,8 +83,7 @@ fun VaultItemScreen( } is VaultItemEvent.NavigateToMoveToOrganization -> { - // TODO Implement move to organization in BIT-844 - Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show() + onNavigateToMoveToOrganization(event.itemId) } is VaultItemEvent.ShowToast -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationNavigation.kt new file mode 100644 index 0000000000..fe5bb396f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationNavigation.kt @@ -0,0 +1,56 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val VAULT_MOVE_TO_ORGANIZATION_PREFIX = "vault_move_to_organization" +private const val VAULT_MOVE_TO_ORGANIZATION_ID = "vault_move_to_organization_id" +private const val VAULT_MOVE_TO_ORGANIZATION_ROUTE = + "$VAULT_MOVE_TO_ORGANIZATION_PREFIX/{$VAULT_MOVE_TO_ORGANIZATION_ID}" + +/** + * Class to retrieve vault move to organization arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class VaultMoveToOrganizationArgs(val vaultItemId: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + checkNotNull(savedStateHandle[VAULT_MOVE_TO_ORGANIZATION_ID]) as String, + ) +} + +/** + * Add the vault move to organization screen to the nav graph. + */ +fun NavGraphBuilder.vaultMoveToOrganizationDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = VAULT_MOVE_TO_ORGANIZATION_ROUTE, + arguments = listOf( + navArgument(VAULT_MOVE_TO_ORGANIZATION_ID) { type = NavType.StringType }, + ), + ) { + VaultMoveToOrganizationScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the vault move to organization screen. + */ +fun NavController.navigateToVaultMoveToOrganization( + vaultItemId: String, + navOptions: NavOptions? = null, +) { + navigate( + route = "$VAULT_MOVE_TO_ORGANIZATION_PREFIX/$vaultItemId", + navOptions = navOptions, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt new file mode 100644 index 0000000000..07aaa30455 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreen.kt @@ -0,0 +1,97 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import android.widget.Toast +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.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.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar + +/** + * Displays the vault move to organization screen. + */ +@Composable +fun VaultMoveToOrganizationScreen( + viewModel: VaultMoveToOrganizationViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is VaultMoveToOrganizationEvent.NavigateBack -> onNavigateBack() + is VaultMoveToOrganizationEvent.ShowToast -> { + Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() + } + } + } + VaultMoveToOrganizationScaffold( + state = state, + closeClick = remember(viewModel) { + { viewModel.trySendAction(VaultMoveToOrganizationAction.BackClick) } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VaultMoveToOrganizationScaffold( + state: VaultMoveToOrganizationState, + closeClick: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.move_to_organization), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = closeClick, + ) + }, + ) { innerPadding -> + val modifier = Modifier + .imePadding() + .fillMaxSize() + .padding(innerPadding) + + when (state.viewState) { + is VaultMoveToOrganizationState.ViewState.Content -> { + // TODO add real views in BIT-844 UI + Text(text = "Content") + } + is VaultMoveToOrganizationState.ViewState.Error -> { + BitwardenErrorContent( + message = state.viewState.message(), + modifier = modifier, + ) + } + is VaultMoveToOrganizationState.ViewState.Loading -> { + BitwardenLoadingContent(modifier = modifier) + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt new file mode 100644 index 0000000000..c702461db3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * ViewModel responsible for handling user interactions in the [VaultMoveToOrganizationScreen]. + * + * @param savedStateHandle Handles the navigation arguments of this ViewModel. + */ +@HiltViewModel +@Suppress("MaxLineLength") +class VaultMoveToOrganizationViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: run { + VaultMoveToOrganizationState( + vaultItemId = VaultMoveToOrganizationArgs(savedStateHandle).vaultItemId, + viewState = VaultMoveToOrganizationState.ViewState.Loading, + ) + }, +) { + + override fun handleAction(action: VaultMoveToOrganizationAction) { + when (action) { + is VaultMoveToOrganizationAction.BackClick -> handleBackClick() + } + } + + private fun handleBackClick() { + sendEvent(VaultMoveToOrganizationEvent.NavigateBack) + } +} + +/** + * Models state for the [VaultMoveToOrganizationScreen]. + * + * @property vaultItemId Indicates whether the VM is in add or edit mode. + * @property viewState indicates what view state the screen is in. + */ +@Parcelize +data class VaultMoveToOrganizationState( + val vaultItemId: String, + val viewState: ViewState, +) : Parcelable { + + /** + * Represents the specific view states for the [VaultMoveToOrganizationScreen]. + */ + sealed class ViewState : Parcelable { + /** + * Represents an error state for the [VaultMoveToOrganizationScreen]. + * + * @property message the error message to display. + */ + @Parcelize + data class Error( + val message: Text, + ) : ViewState() + + /** + * Represents a loading state for the [VaultMoveToOrganizationScreen]. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a loaded content state for the [VaultMoveToOrganizationScreen]. + */ + @Parcelize + data object Content : ViewState() + } +} + +/** + * Models events for the [VaultMoveToOrganizationScreen]. + */ +sealed class VaultMoveToOrganizationEvent { + + /** + * Navigates back to the previous screen. + */ + data object NavigateBack : VaultMoveToOrganizationEvent() + + /** + * Show a toast with the given message. + * + * @property text the text to display. + */ + data class ShowToast(val text: Text) : VaultMoveToOrganizationEvent() +} + +/** + * Models actions for the [VaultMoveToOrganizationScreen]. + */ +sealed class VaultMoveToOrganizationAction { + + /** + * Click the back button. + */ + data object BackClick : VaultMoveToOrganizationAction() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index e7940bb141..c55174db08 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -51,6 +51,7 @@ class VaultItemScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false private var onNavigateToVaultEditItemId: String? = null + private var onNavigateToMoveToOrganizationItemId: String? = null private val intentHandler = mockk() @@ -68,6 +69,7 @@ class VaultItemScreenTest : BaseComposeTest() { viewModel = viewModel, onNavigateBack = { onNavigateBackCalled = true }, onNavigateToVaultAddEditItem = { onNavigateToVaultEditItemId = it }, + onNavigateToMoveToOrganization = { onNavigateToMoveToOrganizationItemId = it }, intentHandler = intentHandler, ) } @@ -81,6 +83,13 @@ class VaultItemScreenTest : BaseComposeTest() { assertEquals(id, onNavigateToVaultEditItemId) } + @Test + fun `NavigateToMoveToOrganization event should invoke onNavigateToMoveToOrganization`() { + val id = "id1234" + mutableEventFlow.tryEmit(VaultItemEvent.NavigateToMoveToOrganization(itemId = id)) + assertEquals(id, onNavigateToMoveToOrganizationItemId) + } + @Test fun `on close click should send CloseClick`() { composeTestRule.onNodeWithContentDescription(label = "Close").performClick() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt new file mode 100644 index 0000000000..9610d0910f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt @@ -0,0 +1,61 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class VaultMoveToOrganizationScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(createVaultMoveToOrganizationState()) + + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setup() { + composeTestRule.setContent { + VaultMoveToOrganizationScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(VaultMoveToOrganizationEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `clicking close button should send BackClick action`() { + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + + verify { + viewModel.trySendAction( + VaultMoveToOrganizationAction.BackClick, + ) + } + } +} + +private fun createVaultMoveToOrganizationState(): VaultMoveToOrganizationState = + VaultMoveToOrganizationState( + vaultItemId = "mockId", + viewState = VaultMoveToOrganizationState.ViewState.Content, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt new file mode 100644 index 0000000000..60fac3ad19 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -0,0 +1,77 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization + +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 VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { + + private val initialState = createVaultMoveToOrganizationState() + private val initialSavedStateHandle = createSavedStateHandleWithState( + state = initialState, + ) + + @Test + fun `initial state should be correct when state is null`() = runTest { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = null, + ), + ) + assertEquals( + initialState.copy(viewState = VaultMoveToOrganizationState.ViewState.Loading), + viewModel.stateFlow.value, + ) + } + + @Test + fun `initial state should be correct`() = runTest { + val initState = createVaultMoveToOrganizationState() + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initState, + ), + ) + + assertEquals(initState, viewModel.stateFlow.value) + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel( + savedStateHandle = initialSavedStateHandle, + ) + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.BackClick) + assertEquals(VaultMoveToOrganizationEvent.NavigateBack, awaitItem()) + } + } + + private fun createViewModel( + savedStateHandle: SavedStateHandle = initialSavedStateHandle, + ): VaultMoveToOrganizationViewModel = + VaultMoveToOrganizationViewModel( + savedStateHandle = savedStateHandle, + ) + + private fun createSavedStateHandleWithState( + state: VaultMoveToOrganizationState? = null, + vaultItemId: String = "mockId", + ) = SavedStateHandle().apply { + set("state", state) + set("vault_move_to_organization_id", vaultItemId) + } + + @Suppress("MaxLineLength") + private fun createVaultMoveToOrganizationState( + viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Content, + vaultItemId: String = "mockId", + ): VaultMoveToOrganizationState = + VaultMoveToOrganizationState( + vaultItemId = vaultItemId, + viewState = viewState, + ) +}