mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 12:44:17 -05:00
[PM-29297] Add MigrateToMyItemsScreen (#6239)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the migrate to my items screen.
|
||||
*
|
||||
* @property organizationId The ID of the organization requiring migration.
|
||||
* @property organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data class MigrateToMyItemsRoute(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Class to retrieve migrate to my items arguments from the [SavedStateHandle].
|
||||
*
|
||||
* @property organizationId The ID of the organization requiring migration.
|
||||
* @property organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
data class MigrateToMyItemsArgs(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Constructs a [MigrateToMyItemsArgs] from the [SavedStateHandle] and internal route data.
|
||||
*/
|
||||
fun SavedStateHandle.toMigrateToMyItemsArgs(): MigrateToMyItemsArgs {
|
||||
val route = this.toRoute<MigrateToMyItemsRoute>()
|
||||
return MigrateToMyItemsArgs(
|
||||
organizationId = route.organizationId,
|
||||
organizationName = route.organizationName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the migrate to my items screen.
|
||||
*
|
||||
* @param organizationId The ID of the organization requiring migration.
|
||||
* @param organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
fun NavController.navigateToMigrateToMyItems(
|
||||
organizationId: String,
|
||||
organizationName: String,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = MigrateToMyItemsRoute(
|
||||
organizationId = organizationId,
|
||||
organizationName = organizationName,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the migrate to my items screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.migrateToMyItemsDestination(
|
||||
onNavigateToVault: () -> Unit,
|
||||
onNavigateToLeaveOrganization: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<MigrateToMyItemsRoute> {
|
||||
MigrateToMyItemsScreen(
|
||||
onNavigateToVault = onNavigateToVault,
|
||||
onNavigateToLeaveOrganization = onNavigateToLeaveOrganization,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler.rememberMigrateToMyItemsHandler
|
||||
|
||||
/**
|
||||
* Top level screen component for the MigrateToMyItems screen.
|
||||
*/
|
||||
@Composable
|
||||
fun MigrateToMyItemsScreen(
|
||||
onNavigateToVault: () -> Unit,
|
||||
onNavigateToLeaveOrganization: () -> Unit,
|
||||
viewModel: MigrateToMyItemsViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberMigrateToMyItemsHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
MigrateToMyItemsEvent.NavigateToVault -> onNavigateToVault()
|
||||
MigrateToMyItemsEvent.NavigateToLeaveOrganization -> onNavigateToLeaveOrganization()
|
||||
is MigrateToMyItemsEvent.LaunchUri -> intentManager.launchUri(event.uri.toUri())
|
||||
}
|
||||
}
|
||||
|
||||
MigrateToMyItemsDialogs(
|
||||
dialog = state.dialog,
|
||||
onDismissRequest = handlers.onDismissDialog,
|
||||
)
|
||||
|
||||
BitwardenScaffold {
|
||||
MigrateToMyItemsContent(
|
||||
state = state,
|
||||
onAcceptClick = handlers.onAcceptClick,
|
||||
onDeclineClick = handlers.onDeclineClick,
|
||||
onHelpClick = handlers.onHelpClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsDialogs(
|
||||
dialog: MigrateToMyItemsState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialog) {
|
||||
is MigrateToMyItemsState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = dialog.title(),
|
||||
message = dialog.message(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
is MigrateToMyItemsState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(text = dialog.message())
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsContent(
|
||||
state: MigrateToMyItemsState,
|
||||
onAcceptClick: () -> Unit,
|
||||
onDeclineClick: () -> Unit,
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_migrate_to_my_items),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(100.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MigrateToMyItemsTextContent(organizationName = state.organizationName)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MigrateToMyItemsActions(
|
||||
onContinueClick = onAcceptClick,
|
||||
onDeclineClick = onDeclineClick,
|
||||
onHelpClick = onHelpClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsTextContent(
|
||||
organizationName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.transfer_items_to_org,
|
||||
organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.transfer_items_description,
|
||||
organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsActions(
|
||||
onContinueClick: () -> Unit,
|
||||
onDeclineClick: () -> Unit,
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.accept),
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.decline_and_leave),
|
||||
onClick = onDeclineClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
|
||||
onClick = onHelpClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MigrateToMyItemsScreen_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
MigrateToMyItemsContent(
|
||||
state = MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Bitwarden",
|
||||
dialog = null,
|
||||
),
|
||||
onAcceptClick = {},
|
||||
onDeclineClick = {},
|
||||
onHelpClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* View model for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MigrateToMyItemsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val args = savedStateHandle.toMigrateToMyItemsArgs()
|
||||
MigrateToMyItemsState(
|
||||
organizationId = args.organizationId,
|
||||
organizationName = args.organizationName,
|
||||
dialog = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: MigrateToMyItemsAction) {
|
||||
when (action) {
|
||||
MigrateToMyItemsAction.AcceptClicked -> handleAcceptClicked()
|
||||
MigrateToMyItemsAction.DeclineAndLeaveClicked -> handleDeclineAndLeaveClicked()
|
||||
MigrateToMyItemsAction.HelpLinkClicked -> handleHelpLinkClicked()
|
||||
MigrateToMyItemsAction.DismissDialogClicked -> handleDismissDialogClicked()
|
||||
is MigrateToMyItemsAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAcceptClicked() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MigrateToMyItemsState.DialogState.Loading(
|
||||
message = BitwardenString.migrating_items_to_x.asText(
|
||||
it.organizationName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// TODO: Replace `delay` with actual migration using `state.organizationId` (PM-28444).
|
||||
delay(timeMillis = 100L)
|
||||
trySendAction(
|
||||
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
|
||||
success = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeclineAndLeaveClicked() {
|
||||
sendEvent(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
|
||||
}
|
||||
|
||||
private fun handleHelpLinkClicked() {
|
||||
sendEvent(
|
||||
MigrateToMyItemsEvent.LaunchUri(
|
||||
uri = "https://bitwarden.com/help/transfer-ownership/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDismissDialogClicked() {
|
||||
clearDialog()
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: MigrateToMyItemsAction.Internal) {
|
||||
when (action) {
|
||||
is MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived -> {
|
||||
handleMigrateToMyItemsResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMigrateToMyItemsResultReceived(
|
||||
action: MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived,
|
||||
) {
|
||||
if (action.success) {
|
||||
clearDialog()
|
||||
sendEvent(MigrateToMyItemsEvent.NavigateToVault)
|
||||
} else {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MigrateToMyItemsState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.failed_to_migrate_items_to_x.asText(
|
||||
it.organizationName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the state for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class MigrateToMyItemsState(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Models the dialog state for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
|
||||
/**
|
||||
* Displays a loading dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
|
||||
/**
|
||||
* Displays an error dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the events that can be sent from the [MigrateToMyItemsViewModel].
|
||||
*/
|
||||
sealed class MigrateToMyItemsEvent {
|
||||
/**
|
||||
* Navigate to the vault screen after accepting migration.
|
||||
*/
|
||||
data object NavigateToVault : MigrateToMyItemsEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the leave organization flow after declining.
|
||||
*/
|
||||
data object NavigateToLeaveOrganization : MigrateToMyItemsEvent()
|
||||
|
||||
/**
|
||||
* Launch a URI in the browser or appropriate handler.
|
||||
*/
|
||||
data class LaunchUri(val uri: String) : MigrateToMyItemsEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the actions that can be handled by the [MigrateToMyItemsViewModel].
|
||||
*/
|
||||
sealed class MigrateToMyItemsAction {
|
||||
/**
|
||||
* User clicked the Accept button.
|
||||
*/
|
||||
data object AcceptClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User clicked the Decline and Leave button.
|
||||
*/
|
||||
data object DeclineAndLeaveClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User clicked the "Why am I seeing this?" help link.
|
||||
*/
|
||||
data object HelpLinkClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User dismissed the dialog.
|
||||
*/
|
||||
data object DismissDialogClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* Models internal actions that the [MigrateToMyItemsViewModel] itself may send.
|
||||
*/
|
||||
sealed class Internal : MigrateToMyItemsAction() {
|
||||
|
||||
/**
|
||||
* The result of the migration has been received.
|
||||
*/
|
||||
data class MigrateToMyItemsResultReceived(
|
||||
// TODO: Replace `success` with actual migration result (PM-28444).
|
||||
val success: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsScreen
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsViewModel
|
||||
|
||||
/**
|
||||
* Action handlers for the [MigrateToMyItemsScreen].
|
||||
*
|
||||
* @property onAcceptClick Handler for when the user clicks the Accept button to accept migration.
|
||||
* @property onDeclineClick Handler for when the user clicks the decline and leave button.
|
||||
* @property onHelpClick Handler for when the user clicks the help link.
|
||||
* @property onDismissDialog Handler for when the user dismisses a dialog.
|
||||
*/
|
||||
class MigrateToMyItemsHandler(
|
||||
val onAcceptClick: () -> Unit,
|
||||
val onDeclineClick: () -> Unit,
|
||||
val onHelpClick: () -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Creates an instance of [MigrateToMyItemsHandler] using the provided
|
||||
* [MigrateToMyItemsViewModel].
|
||||
*/
|
||||
fun create(viewModel: MigrateToMyItemsViewModel) = MigrateToMyItemsHandler(
|
||||
onAcceptClick = {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
|
||||
},
|
||||
onDeclineClick = {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
|
||||
},
|
||||
onHelpClick = {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remember a [MigrateToMyItemsHandler] instance in a [Composable] scope.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMigrateToMyItemsHandler(viewModel: MigrateToMyItemsViewModel): MigrateToMyItemsHandler =
|
||||
remember(viewModel) { MigrateToMyItemsHandler.create(viewModel) }
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
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 org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
|
||||
private var onNavigateToVaultCalled = false
|
||||
private var onNavigateToLeaveOrganizationCalled = false
|
||||
|
||||
private val intentManager: IntentManager = mockk {
|
||||
every { launchUri(any()) } just runs
|
||||
}
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MigrateToMyItemsEvent>()
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Test Organization",
|
||||
dialog = null,
|
||||
),
|
||||
)
|
||||
|
||||
private val viewModel = mockk<MigrateToMyItemsViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent(intentManager = intentManager) {
|
||||
MigrateToMyItemsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToVault = { onNavigateToVaultCalled = true },
|
||||
onNavigateToLeaveOrganization = { onNavigateToLeaveOrganizationCalled = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title should display with organization name`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Transfer items to Test Organization")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `description text should be displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(
|
||||
"Test Organization is requiring all items to be owned by the " +
|
||||
"organization for security and compliance. Click accept to transfer " +
|
||||
"ownership of your items.",
|
||||
substring = true,
|
||||
)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Accept button click should send AcceptClicked action`() {
|
||||
composeTestRule.onNodeWithText("Accept").performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Decline and leave button click should send DeclineAndLeaveClicked action`() {
|
||||
composeTestRule.onNodeWithText("Decline and leave").performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Why am I seeing this link click should send HelpLinkClicked action`() {
|
||||
composeTestRule.onNodeWithText("Why am I seeing this?").performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToVault event should trigger navigation callback`() {
|
||||
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.NavigateToVault)
|
||||
assertTrue(onNavigateToVaultCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToLeaveOrganization event should trigger navigation callback`() {
|
||||
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
|
||||
assertTrue(onNavigateToLeaveOrganizationCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LaunchUri event should launch URI via intent manager`() {
|
||||
val testUri = "https://bitwarden.com/help/transfer-ownership/"
|
||||
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.LaunchUri(testUri))
|
||||
verify {
|
||||
intentManager.launchUri(testUri.toUri())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Loading dialog should display when dialog state is Loading`() {
|
||||
mutableStateFlow.value = MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Test Organization",
|
||||
dialog = MigrateToMyItemsState.DialogState.Loading(
|
||||
message = "Migrating items to Test Organization...".asText(),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Migrating items to Test Organization...")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error dialog should display when dialog state is Error`() {
|
||||
mutableStateFlow.value = MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Test Organization",
|
||||
dialog = MigrateToMyItemsState.DialogState.Error(
|
||||
title = "An error has occurred".asText(),
|
||||
message = "Failed to migrate items".asText(),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("An error has occurred")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Failed to migrate items")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error dialog dismiss should send DismissDialogClicked action`() {
|
||||
mutableStateFlow.value = MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Test Organization",
|
||||
dialog = MigrateToMyItemsState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = "Failed to migrate items".asText(),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Okay")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
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.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be set from organization data`() {
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(ORGANIZATION_NAME, viewModel.stateFlow.value.organizationName)
|
||||
assertNull(viewModel.stateFlow.value.dialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AcceptClicked should show loading dialog and trigger migration`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(null, awaitItem().dialog)
|
||||
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assert(loadingState.dialog is MigrateToMyItemsState.DialogState.Loading)
|
||||
assertEquals(
|
||||
BitwardenString.migrating_items_to_x.asText(ORGANIZATION_NAME),
|
||||
(loadingState.dialog as MigrateToMyItemsState.DialogState.Loading).message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AcceptClicked should navigate to vault on success`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
|
||||
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MigrateToMyItemsResultReceived with success should clear dialog and navigate to vault`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// First show the loading dialog
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
|
||||
success = true,
|
||||
),
|
||||
)
|
||||
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
|
||||
}
|
||||
|
||||
assertNull(viewModel.stateFlow.value.dialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MigrateToMyItemsResultReceived with failure should show error dialog`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
awaitItem() // Initial state
|
||||
|
||||
viewModel.trySendAction(
|
||||
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
|
||||
success = false,
|
||||
),
|
||||
)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assert(errorState.dialog is MigrateToMyItemsState.DialogState.Error)
|
||||
val errorDialog = errorState.dialog as MigrateToMyItemsState.DialogState.Error
|
||||
assertEquals(BitwardenString.an_error_has_occurred.asText(), errorDialog.title)
|
||||
assertEquals(
|
||||
BitwardenString.failed_to_migrate_items_to_x.asText(ORGANIZATION_NAME),
|
||||
errorDialog.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeclineAndLeaveClicked sends NavigateToLeaveOrganization event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
|
||||
assertEquals(MigrateToMyItemsEvent.NavigateToLeaveOrganization, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HelpLinkClicked sends LaunchUri event with help URL`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.HelpLinkClicked)
|
||||
val event = awaitItem()
|
||||
assert(event is MigrateToMyItemsEvent.LaunchUri)
|
||||
assertEquals(
|
||||
"https://bitwarden.com/help/transfer-ownership/",
|
||||
(event as MigrateToMyItemsEvent.LaunchUri).uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialogClicked should clear dialog`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
awaitItem() // Initial state
|
||||
|
||||
// First show an error dialog
|
||||
viewModel.trySendAction(
|
||||
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
|
||||
success = false,
|
||||
),
|
||||
)
|
||||
val errorState = awaitItem()
|
||||
assert(errorState.dialog is MigrateToMyItemsState.DialogState.Error)
|
||||
|
||||
// Dismiss the dialog
|
||||
viewModel.trySendAction(MigrateToMyItemsAction.DismissDialogClicked)
|
||||
val clearedState = awaitItem()
|
||||
assertNull(clearedState.dialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(),
|
||||
): MigrateToMyItemsViewModel {
|
||||
every { savedStateHandle.toMigrateToMyItemsArgs() } returns MigrateToMyItemsArgs(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
)
|
||||
return MigrateToMyItemsViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "test-organization-id"
|
||||
private const val ORGANIZATION_NAME = "Test Organization"
|
||||
Reference in New Issue
Block a user