PM-21397: Create initial View Send scaffold (#5163)

This commit is contained in:
David Perez
2025-05-08 17:55:07 -05:00
committed by GitHub
parent d4d5d2c2a8
commit 652168f946
7 changed files with 502 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ViewSendState, ViewSendEvent, ViewSendAction>(
// 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()
}

View File

@@ -1266,4 +1266,6 @@ Do you want to switch to this account?</string>
<string name="cannot_delete_your_account">Cannot delete your account</string>
<string name="cannot_delete_your_account_explanation">This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details.</string>
<string name="this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden">This account will soon be deleted. Log in at %1$s to continue using Bitwarden.</string>
<string name="view_file_send">View file Send</string>
<string name="view_text_send">View text Send</string>
</resources>

View File

@@ -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<ViewSendEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ViewSendViewModel> {
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,
)

View File

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