diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemNavigation.kt new file mode 100644 index 000000000..b06a903d0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreen.kt new file mode 100644 index 000000000..f01f85129 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreen.kt @@ -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 -> } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModel.kt new file mode 100644 index 000000000..0acabe8c8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModel.kt @@ -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( + 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() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreenTest.kt new file mode 100644 index 000000000..70107b6e9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemScreenTest.kt @@ -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( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModelTest.kt new file mode 100644 index 000000000..83e54fd87 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/edit/VaultEditItemViewModelTest.kt @@ -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, +)