From 652168f9467866b1822c4665df8755c6654fc33f Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 8 May 2025 17:55:07 -0500 Subject: [PATCH] PM-21397: Create initial View Send scaffold (#5163) --- .../tools/feature/send/model/SendItemType.kt | 12 ++ .../send/viewsend/ViewSendNavigation.kt | 60 +++++++++ .../feature/send/viewsend/ViewSendScreen.kt | 110 +++++++++++++++ .../send/viewsend/ViewSendViewModel.kt | 127 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + .../send/viewsend/ViewSendScreenTest.kt | 110 +++++++++++++++ .../send/viewsend/ViewSendViewModelTest.kt | 81 +++++++++++ 7 files changed, 502 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt new file mode 100644 index 0000000000..86dc009c15 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.model + +import kotlinx.serialization.Serializable + +/** + * Represents different types of sends that can be added/viewed. + */ +@Serializable +enum class SendItemType { + FILE, + TEXT, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendNavigation.kt new file mode 100644 index 0000000000..63ea160419 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendNavigation.kt @@ -0,0 +1,60 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.viewsend + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the view send screen. + */ +@Serializable +data class ViewSendRoute( + val sendId: String, + val sendType: SendItemType, +) + +/** + * Class to retrieve vault item arguments from the [SavedStateHandle]. + */ +data class ViewSendArgs( + val sendId: String, + val sendType: SendItemType, +) + +/** + * Constructs a [ViewSendArgs] from the [SavedStateHandle] and internal route data. + */ +fun SavedStateHandle.toViewSendArgs(): ViewSendArgs { + val route = this.toRoute() + return ViewSendArgs(sendId = route.sendId, sendType = route.sendType) +} + +/** + * Add the view send screen to the nav graph. + */ +fun NavGraphBuilder.viewSendDestination( + onNavigateBack: () -> Unit, + onNavigateToEditSend: (sendId: String) -> Unit, +) { + composableWithSlideTransitions { + ViewSendScreen( + onNavigateBack = onNavigateBack, + onNavigateToEditSend = onNavigateToEditSend, + ) + } +} + +/** + * Navigate to the view send screen. + */ +fun NavController.navigateToViewSend( + route: ViewSendRoute, + navOptions: NavOptions? = null, +) { + this.navigate(route = route, navOptions = navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt new file mode 100644 index 0000000000..a51970c9b8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.viewsend + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.fillMaxSize +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.testTag +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.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter + +/** + * Displays view send screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ViewSendScreen( + viewModel: ViewSendViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, + onNavigateToEditSend: (sendId: String) -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is ViewSendEvent.NavigateBack -> onNavigateBack() + is ViewSendEvent.NavigateToEdit -> onNavigateToEditSend(event.sendId) + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = state.screenDisplayName(), + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(ViewSendAction.CloseClick) } + }, + ), + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = state.isFabVisible, + enter = scaleIn(), + exit = scaleOut(), + ) { + BitwardenFloatingActionButton( + onClick = remember(viewModel) { + { viewModel.trySendAction(ViewSendAction.EditClick) } + }, + painter = rememberVectorPainter(id = R.drawable.ic_pencil), + contentDescription = stringResource(id = R.string.edit_send), + modifier = Modifier.testTag(tag = "EditItemButton"), + ) + } + }, + ) { + ViewSendScreenContent( + state = state, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun ViewSendScreenContent( + state: ViewSendState, + modifier: Modifier = Modifier, +) { + when (val viewState = state.viewState) { + ViewSendState.ViewState.Content -> { + // TODO: Build out the UI (PM-21135) + } + + is ViewSendState.ViewState.Error -> { + BitwardenErrorContent( + message = viewState.message(), + modifier = modifier, + ) + } + + ViewSendState.ViewState.Loading -> { + BitwardenLoadingContent(modifier = modifier) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt new file mode 100644 index 0000000000..8dad630fe2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt @@ -0,0 +1,127 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.viewsend + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the view send screen. + */ +@HiltViewModel +class ViewSendViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] ?: run { + val args = savedStateHandle.toViewSendArgs() + ViewSendState( + sendType = args.sendType, + sendId = args.sendId, + viewState = ViewSendState.ViewState.Loading, + ) + }, +) { + override fun handleAction(action: ViewSendAction) { + when (action) { + ViewSendAction.CloseClick -> handleCloseClick() + ViewSendAction.EditClick -> handleEditClick() + } + } + + private fun handleCloseClick() { + sendEvent(ViewSendEvent.NavigateBack) + } + + private fun handleEditClick() { + sendEvent(ViewSendEvent.NavigateToEdit(sendType = state.sendType, sendId = state.sendId)) + } +} + +/** + * Models state for the new send screen. + */ +@Parcelize +data class ViewSendState( + val sendType: SendItemType, + val sendId: String, + val viewState: ViewState, +) : Parcelable { + /** + * Helper to determine the screen display name. + */ + val screenDisplayName: Text + get() = when (sendType) { + SendItemType.FILE -> R.string.view_file_send.asText() + SendItemType.TEXT -> R.string.view_text_send.asText() + } + + /** + * Whether or not the fab is visible. + */ + val isFabVisible: Boolean get() = viewState is ViewState.Content + + /** + * Represents the specific view states for the view send screen. + */ + sealed class ViewState : Parcelable { + /** + * Represents an error state for the view send screen. + */ + @Parcelize + data class Error(val message: Text) : ViewState() + + /** + * Loading state for the view send screen, signifying that the content is being processed. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a loaded content state for the view send screen. + */ + @Parcelize + data object Content : ViewState() + } +} + +/** + * Models events for the view send screen. + */ +sealed class ViewSendEvent { + /** + * Navigate back. + */ + data object NavigateBack : ViewSendEvent() + + /** + * Navigate to the edit send screen for the current send. + */ + data class NavigateToEdit( + val sendType: SendItemType, + val sendId: String, + ) : ViewSendEvent() +} + +/** + * Models actions for the view send screen. + */ +sealed class ViewSendAction { + /** + * The user has clicked the close button. + */ + data object CloseClick : ViewSendAction() + + /** + * The user has clicked the edit button. + */ + data object EditClick : ViewSendAction() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 115bb74734..5ec09c2dd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1266,4 +1266,6 @@ Do you want to switch to this account? Cannot delete your account This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details. This account will soon be deleted. Log in at %1$s to continue using Bitwarden. + View file Send + View text Send diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt new file mode 100644 index 0000000000..d6a9490c56 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.viewsend + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType +import com.x8bit.bitwarden.ui.util.isProgressBar +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ViewSendScreenTest : BaseComposeTest() { + private var onNavigateBackCalled: Boolean = false + private var onNavigateToEditData: String? = null + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + every { trySendAction(action = any()) } just runs + } + + @Before + fun setup() { + setContent { + ViewSendScreen( + viewModel = viewModel, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToEditSend = { onNavigateToEditData = it }, + ) + } + } + + @Test + fun `on NavigateBack event should call onNavigateBack`() { + mutableEventFlow.tryEmit(ViewSendEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `on NavigateToEdit event should call onNavigateToEdit`() { + val sendType = SendItemType.TEXT + val sendId = "send_id" + mutableEventFlow.tryEmit(ViewSendEvent.NavigateToEdit(sendType = sendType, sendId = sendId)) + assertEquals(sendId, onNavigateToEditData) + } + + @Test + fun `on close click should send CloseClick`() { + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(ViewSendAction.CloseClick) + } + } + + @Test + fun `on edit click should send EditClick`() { + composeTestRule + .onNodeWithContentDescription(label = "Edit Send") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(ViewSendAction.EditClick) + } + } + + @Test + fun `progress bar should be displayed based on ViewState`() { + mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) } + // There are 2 because of the pull-to-refresh + composeTestRule.onAllNodes(isProgressBar).assertCountEquals(2) + + mutableStateFlow.update { it.copy(viewState = DEFAULT_STATE.viewState) } + // Only pull-to-refresh remains + composeTestRule.onAllNodes(isProgressBar).assertCountEquals(1) + } + + @Test + fun `error should be displayed based on ViewState`() { + val errorMessage = "Fail!" + mutableStateFlow.update { + it.copy(viewState = ViewSendState.ViewState.Error(message = errorMessage.asText())) + } + composeTestRule.onNodeWithText(text = errorMessage).assertIsDisplayed() + + mutableStateFlow.update { it.copy(viewState = DEFAULT_STATE.viewState) } + composeTestRule.onNodeWithText(text = errorMessage).assertIsNotDisplayed() + } +} + +private val DEFAULT_STATE = ViewSendState( + sendType = SendItemType.TEXT, + sendId = "send_id", + viewState = ViewSendState.ViewState.Content, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt new file mode 100644 index 0000000000..9642b08375 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt @@ -0,0 +1,81 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.viewsend + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ViewSendViewModelTest : BaseViewModelTest() { + @BeforeEach + fun setup() { + mockkStatic( + SavedStateHandle::toViewSendArgs, + ) + } + + @AfterEach + fun tearDown() { + unmockkStatic( + SavedStateHandle::toViewSendArgs, + ) + } + + @Test + fun `initial state should be correct`() { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy(viewState = ViewSendState.ViewState.Loading), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on CloseClick should send NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ViewSendAction.CloseClick) + assertEquals(ViewSendEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `on EditClick should send NavigateToEdit`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ViewSendAction.EditClick) + assertEquals( + ViewSendEvent.NavigateToEdit( + sendType = DEFAULT_STATE.sendType, + sendId = DEFAULT_STATE.sendId, + ), + awaitItem(), + ) + } + } + + private fun createViewModel( + state: ViewSendState? = null, + ): ViewSendViewModel = ViewSendViewModel( + savedStateHandle = SavedStateHandle().apply { + set(key = "state", value = state) + every { toViewSendArgs() } returns ViewSendArgs( + sendId = (state ?: DEFAULT_STATE).sendId, + sendType = (state ?: DEFAULT_STATE).sendType, + ) + }, + ) +} + +private val DEFAULT_STATE = ViewSendState( + sendType = SendItemType.TEXT, + sendId = "send_id", + viewState = ViewSendState.ViewState.Content, +)