BIT-481: Implement primary Send UI and sharing (#510)

This commit is contained in:
David Perez 2024-01-06 10:04:49 -06:00 committed by Álison Fernandes
parent 4a39f126dd
commit e0231f511f
5 changed files with 126 additions and 17 deletions

View file

@ -52,4 +52,15 @@ class IntentHandler(private val context: Context) {
}
startActivity(Intent(Intent.ACTION_VIEW, newUri))
}
/**
* Launches the share sheet with the given [text].
*/
fun shareText(text: String) {
val sendIntent: Intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
startActivity(Intent.createChooser(sendIntent, null))
}
}

View file

@ -29,10 +29,12 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.tools.feature.send.handlers.SendHandlers
import kotlinx.collections.immutable.persistentListOf
@ -58,6 +60,10 @@ fun SendScreen(
intentHandler.launchUri("https://bitwarden.com/products/send".toUri())
}
is SendEvent.ShowShareSheet -> {
intentHandler.shareText(event.url)
}
is SendEvent.ShowToast -> {
Toast
.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT)
@ -66,6 +72,10 @@ fun SendScreen(
}
}
SendDialogs(
dialogState = state.dialogState,
)
val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
state = rememberTopAppBarState(),
@ -159,3 +169,16 @@ fun SendScreen(
}
}
}
@Composable
private fun SendDialogs(
dialogState: SendState.DialogState?,
) {
when (dialogState) {
is SendState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.message),
)
null -> Unit
}
}

View file

@ -43,6 +43,7 @@ class SendViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE]
?: SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
),
) {
@ -81,6 +82,7 @@ class SendViewModel @Inject constructor(
viewState = SendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
dialogState = null,
)
}
}
@ -94,6 +96,7 @@ class SendViewModel @Inject constructor(
.environmentUrlData
.baseWebSendUrl,
),
dialogState = null,
)
}
}
@ -112,6 +115,7 @@ class SendViewModel @Inject constructor(
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
dialogState = null,
)
}
}
@ -154,7 +158,9 @@ class SendViewModel @Inject constructor(
}
private fun handleSyncClick() {
// TODO: Add loading dialog state BIT-481
mutableStateFlow.update {
it.copy(dialogState = SendState.DialogState.Loading(R.string.syncing.asText()))
}
vaultRepo.sync()
}
@ -163,22 +169,21 @@ class SendViewModel @Inject constructor(
}
private fun handleSendClick(action: SendAction.SendClick) {
// TODO: Navigate to the edit send screen BIT-??
// TODO: Navigate to the edit send screen (BIT-1387)
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleShareClick(action: SendAction.ShareClick) {
// TODO: Create a link and use the share sheet BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
sendEvent(SendEvent.ShowShareSheet(action.sendItem.shareUrl))
}
private fun handleFileTypeClick() {
// TODO: Navigate to the file type send list screen BIT-??
// TODO: Navigate to the file type send list screen (BIT-1388)
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleTextTypeClick() {
// TODO: Navigate to the text type send list screen BIT-??
// TODO: Navigate to the text type send list screen (BIT-1388)
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
}
@ -189,6 +194,7 @@ class SendViewModel @Inject constructor(
@Parcelize
data class SendState(
val viewState: ViewState,
val dialogState: DialogState?,
) : Parcelable {
/**
@ -259,6 +265,20 @@ data class SendState(
override val shouldDisplayFab: Boolean get() = false
}
}
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
@ -353,6 +373,11 @@ sealed class SendEvent {
*/
data object NavigateToAboutSend : SendEvent()
/**
* Show a share sheet with the given content.
*/
data class ShowShareSheet(val url: String) : SendEvent()
/**
* Show a toast to the user.
*/

View file

@ -41,6 +41,7 @@ class SendScreenTest : BaseComposeTest() {
private val intentHandler = mockk<IntentHandler> {
every { launchUri(any()) } just runs
every { shareText(any()) } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<SendEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -74,6 +75,15 @@ class SendScreenTest : BaseComposeTest() {
}
}
@Test
fun `on ShowShareSheet should call shareText on IntentHandler`() {
val text = "sharable stuff"
mutableEventFlow.tryEmit(SendEvent.ShowShareSheet(text))
verify {
intentHandler.shareText(text)
}
}
@Test
fun `on overflow item click should display menu`() {
composeTestRule
@ -474,10 +484,27 @@ class SendScreenTest : BaseComposeTest() {
.performClick()
composeTestRule.assertNoDialogExists()
}
@Test
fun `loading dialog should be displayed according to state`() {
val loadingMessage = "syncing"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(loadingMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialogState = SendState.DialogState.Loading(loadingMessage.asText()))
}
composeTestRule
.onNodeWithText(loadingMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
}
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
)
private val DEFAULT_SEND_ITEM: SendState.ViewState.Content.SendItem =

View file

@ -114,6 +114,12 @@ class SendViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(SendAction.SyncClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Loading(R.string.syncing.asText()),
),
viewModel.stateFlow.value,
)
verify {
vaultRepo.sync()
}
@ -146,12 +152,15 @@ class SendViewModelTest : BaseViewModelTest() {
}
@Test
fun `ShareClick should emit ShowToast`() = runTest {
fun `ShareClick should emit ShowShareSheet`() = runTest {
val viewModel = createViewModel()
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
val testUrl = "www.test.com"
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { shareUrl } returns testUrl
}
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.ShareClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
assertEquals(SendEvent.ShowShareSheet(testUrl), awaitItem())
}
}
@ -175,7 +184,8 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `VaultRepository SendData Error should update view state to Error`() {
val viewModel = createViewModel()
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
mutableSendDataFlow.value = DataState.Error(Throwable("Fail"))
@ -184,6 +194,7 @@ class SendViewModelTest : BaseViewModelTest() {
viewState = SendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
dialogState = null,
),
viewModel.stateFlow.value,
)
@ -191,7 +202,8 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `VaultRepository SendData Loaded should update view state`() {
val viewModel = createViewModel()
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every {
@ -201,24 +213,29 @@ class SendViewModelTest : BaseViewModelTest() {
mutableSendDataFlow.value = DataState.Loaded(sendData)
assertEquals(SendState(viewState = viewState), viewModel.stateFlow.value)
assertEquals(
SendState(viewState = viewState, dialogState = null),
viewModel.stateFlow.value,
)
}
@Test
fun `VaultRepository SendData Loading should update view state to Loading`() {
val viewModel = createViewModel()
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
mutableSendDataFlow.value = DataState.Loading
assertEquals(
SendState(viewState = SendState.ViewState.Loading),
SendState(viewState = SendState.ViewState.Loading, dialogState = dialogState),
viewModel.stateFlow.value,
)
}
@Test
fun `VaultRepository SendData NoNetwork should update view state to Error`() {
val viewModel = createViewModel()
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
mutableSendDataFlow.value = DataState.NoNetwork()
@ -229,6 +246,7 @@ class SendViewModelTest : BaseViewModelTest() {
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
dialogState = null,
),
viewModel.stateFlow.value,
)
@ -236,7 +254,8 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `VaultRepository SendData Pending should update view state`() {
val viewModel = createViewModel()
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every {
@ -246,7 +265,10 @@ class SendViewModelTest : BaseViewModelTest() {
mutableSendDataFlow.value = DataState.Pending(sendData)
assertEquals(SendState(viewState = viewState), viewModel.stateFlow.value)
assertEquals(
SendState(viewState = viewState, dialogState = dialogState),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
@ -269,4 +291,5 @@ private const val SEND_DATA_EXTENSIONS_PATH: String =
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
)