mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Move to organization navigation (#620)
This commit is contained in:
parent
df3a6598b3
commit
0f0fe81f41
9 changed files with 423 additions and 2 deletions
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<VaultMoveToOrganizationState, VaultMoveToOrganizationEvent, VaultMoveToOrganizationAction>(
|
||||
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()
|
||||
}
|
|
@ -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<IntentHandler>()
|
||||
|
||||
|
@ -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()
|
||||
|
|
|
@ -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<VaultMoveToOrganizationEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(createVaultMoveToOrganizationState())
|
||||
|
||||
private val viewModel = mockk<VaultMoveToOrganizationViewModel>(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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue