Adds the initial boilerplate for VaultEditItemScreen (#326)

This commit is contained in:
David Perez 2023-12-05 16:14:31 -06:00 committed by Álison Fernandes
parent a106f0852a
commit a7dc5fe08f
5 changed files with 346 additions and 0 deletions

View file

@ -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)
}

View file

@ -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 -> }
}

View file

@ -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()
}

View file

@ -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,
)

View file

@ -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,
)