mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Adds the initial boilerplate for VaultEditItemScreen (#326)
This commit is contained in:
parent
a106f0852a
commit
a7dc5fe08f
5 changed files with 346 additions and 0 deletions
|
@ -0,0 +1,53 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.feature.edit
|
||||||
|
|
||||||
|
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_EDIT_ITEM_PREFIX = "vault_edit_item"
|
||||||
|
private const val VAULT_EDIT_ITEM_ID = "vault_edit_item_id"
|
||||||
|
private const val VAULT_EDIT_ROUTE = "$VAULT_EDIT_ITEM_PREFIX/{$VAULT_EDIT_ITEM_ID}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to retrieve vault item arguments from the [SavedStateHandle].
|
||||||
|
*/
|
||||||
|
class VaultEditItemArgs(val vaultItemId: String) {
|
||||||
|
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||||
|
checkNotNull(savedStateHandle[VAULT_EDIT_ITEM_ID]) as String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the vault edit item screen to the nav graph.
|
||||||
|
*/
|
||||||
|
fun NavGraphBuilder.vaultEditItemDestination(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable(
|
||||||
|
route = VAULT_EDIT_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(VAULT_EDIT_ITEM_ID) { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
enterTransition = TransitionProviders.Enter.slideUp,
|
||||||
|
exitTransition = TransitionProviders.Exit.slideDown,
|
||||||
|
popEnterTransition = TransitionProviders.Enter.slideUp,
|
||||||
|
popExitTransition = TransitionProviders.Exit.slideDown,
|
||||||
|
) {
|
||||||
|
VaultEditItemScreen(onNavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the vault edit item screen.
|
||||||
|
*/
|
||||||
|
fun NavController.navigateToVaultEditItem(
|
||||||
|
vaultItemId: String,
|
||||||
|
navOptions: NavOptions? = null,
|
||||||
|
) {
|
||||||
|
navigate("$VAULT_EDIT_ITEM_PREFIX/$vaultItemId", navOptions)
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.feature.edit
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
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.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.BitwardenScaffold
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top level composable for the vault edit item screen.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VaultEditItemScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: VaultEditItemViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources = context.resources
|
||||||
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
|
when (event) {
|
||||||
|
VaultEditItemEvent.NavigateBack -> onNavigateBack()
|
||||||
|
|
||||||
|
is VaultEditItemEvent.ShowToast -> {
|
||||||
|
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
BitwardenScaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.imePadding()
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
BitwardenTopAppBar(
|
||||||
|
title = stringResource(id = R.string.edit_item),
|
||||||
|
navigationIcon = painterResource(id = R.drawable.ic_close),
|
||||||
|
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||||
|
onNavigationIconClick = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultEditItemAction.CloseClick) }
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
actions = {
|
||||||
|
BitwardenTextButton(
|
||||||
|
label = stringResource(id = R.string.save),
|
||||||
|
onClick = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultEditItemAction.SaveClick) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding -> }
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.feature.edit
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
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 edit item screen
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class VaultEditItemViewModel @Inject constructor(
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
|
) : BaseViewModel<VaultEditItemState, VaultEditItemEvent, VaultEditItemAction>(
|
||||||
|
initialState = savedStateHandle[KEY_STATE]
|
||||||
|
?: VaultEditItemState(
|
||||||
|
vaultItemId = VaultEditItemArgs(savedStateHandle).vaultItemId,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
stateFlow
|
||||||
|
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleAction(action: VaultEditItemAction) {
|
||||||
|
when (action) {
|
||||||
|
VaultEditItemAction.CloseClick -> handleCloseClick()
|
||||||
|
VaultEditItemAction.SaveClick -> handleSaveClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCloseClick() {
|
||||||
|
sendEvent(VaultEditItemEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSaveClick() {
|
||||||
|
// TODO: Persist the data to the vault (BIT-502)
|
||||||
|
sendEvent(VaultEditItemEvent.ShowToast("Not yet implemented".asText()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the state for editing an item to the vault.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class VaultEditItemState(
|
||||||
|
val vaultItemId: String,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a set of events that can be emitted during the process of editing an item in the
|
||||||
|
* vault. Each subclass of this sealed class denotes a distinct event that can occur.
|
||||||
|
*/
|
||||||
|
sealed class VaultEditItemEvent {
|
||||||
|
/**
|
||||||
|
* Shows a toast with the given [message].
|
||||||
|
*/
|
||||||
|
data class ShowToast(
|
||||||
|
val message: Text,
|
||||||
|
) : VaultEditItemEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate back to previous screen.
|
||||||
|
*/
|
||||||
|
data object NavigateBack : VaultEditItemEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a set of actions related to the process of editing an item in the vault.
|
||||||
|
* Each subclass of this sealed class denotes a distinct action that can be taken.
|
||||||
|
*/
|
||||||
|
sealed class VaultEditItemAction {
|
||||||
|
/**
|
||||||
|
* Represents the action when the save button is clicked.
|
||||||
|
*/
|
||||||
|
data object SaveClick : VaultEditItemAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User clicked close.
|
||||||
|
*/
|
||||||
|
data object CloseClick : VaultEditItemAction()
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.feature.edit
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VaultEditItemScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
|
private var onNavigateBackCalled = false
|
||||||
|
|
||||||
|
private val mutableEventFlow = MutableSharedFlow<VaultEditItemEvent>(
|
||||||
|
extraBufferCapacity = Int.MAX_VALUE,
|
||||||
|
)
|
||||||
|
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||||
|
private val viewModel = mockk<VaultEditItemViewModel>(relaxed = true) {
|
||||||
|
every { eventFlow } returns mutableEventFlow
|
||||||
|
every { stateFlow } returns mutableStateFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
VaultEditItemScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateBack = { onNavigateBackCalled = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NavigateBack event should invoke onNavigateBack`() {
|
||||||
|
mutableEventFlow.tryEmit(VaultEditItemEvent.NavigateBack)
|
||||||
|
assertTrue(onNavigateBackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on close click should send CloseClick event`() {
|
||||||
|
composeTestRule.onNodeWithContentDescription("Close").performClick()
|
||||||
|
verify(exactly = 1) {
|
||||||
|
viewModel.trySendAction(VaultEditItemAction.CloseClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on save click should send SaveClick event`() {
|
||||||
|
composeTestRule.onNodeWithText("Save").performClick()
|
||||||
|
verify(exactly = 1) {
|
||||||
|
viewModel.trySendAction(VaultEditItemAction.SaveClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_VAULT_ITEM_ID: String = "vault_item_id"
|
||||||
|
|
||||||
|
private val DEFAULT_STATE: VaultEditItemState = VaultEditItemState(
|
||||||
|
vaultItemId = DEFAULT_VAULT_ITEM_ID,
|
||||||
|
)
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.feature.edit
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class VaultEditItemViewModelTest : 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 differentVaultItemId = "something_different"
|
||||||
|
val state = DEFAULT_STATE.copy(vaultItemId = differentVaultItemId)
|
||||||
|
val viewModel = createViewModel(state = state)
|
||||||
|
|
||||||
|
assertEquals(state, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(VaultEditItemAction.CloseClick)
|
||||||
|
assertEquals(VaultEditItemEvent.NavigateBack, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SaveClick should emit ShowToast`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(VaultEditItemAction.SaveClick)
|
||||||
|
assertEquals(VaultEditItemEvent.ShowToast("Not yet implemented".asText()), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createViewModel(
|
||||||
|
state: VaultEditItemState? = DEFAULT_STATE,
|
||||||
|
vaultItemId: String = VAULT_ITEM_ID,
|
||||||
|
): VaultEditItemViewModel = VaultEditItemViewModel(
|
||||||
|
savedStateHandle = SavedStateHandle().apply {
|
||||||
|
set("state", state)
|
||||||
|
set("vault_edit_item_id", vaultItemId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VAULT_ITEM_ID: String = "vault_item_id"
|
||||||
|
|
||||||
|
private val DEFAULT_STATE: VaultEditItemState = VaultEditItemState(
|
||||||
|
vaultItemId = VAULT_ITEM_ID,
|
||||||
|
)
|
Loading…
Reference in a new issue