mirror of
https://github.com/bitwarden/android.git
synced 2026-04-30 12:59:02 -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"
|
||||
119
ui/src/main/res/drawable/ill_migrate_to_my_items.xml
Normal file
119
ui/src/main/res/drawable/ill_migrate_to_my_items.xml
Normal file
@@ -0,0 +1,119 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportHeight="100"
|
||||
android:viewportWidth="100">
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M56.25,8.33C56.25,3.73 59.98,0 64.58,0H72.92C77.52,0 81.25,3.73 81.25,8.33V12.5H56.25V8.33Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M72.92,2.08H64.58C61.13,2.08 58.33,4.88 58.33,8.33V10.42H79.17V8.33C79.17,4.88 76.37,2.08 72.92,2.08ZM64.58,0C59.98,0 56.25,3.73 56.25,8.33V12.5H81.25V8.33C81.25,3.73 77.52,0 72.92,0H64.58Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M48.96,16.67C48.96,12.06 52.69,8.33 57.29,8.33H80.21C84.81,8.33 88.54,12.06 88.54,16.67V22.92H48.96V16.67Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M80.21,10.42H57.29C53.84,10.42 51.04,13.21 51.04,16.67V20.83H86.46V16.67C86.46,13.21 83.66,10.42 80.21,10.42ZM57.29,8.33C52.69,8.33 48.96,12.06 48.96,16.67V22.92H88.54V16.67C88.54,12.06 84.81,8.33 80.21,8.33H57.29Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M41.67,27.08C41.67,22.48 45.4,18.75 50,18.75H87.5C92.1,18.75 95.83,22.48 95.83,27.08V93.75C95.83,94.9 94.9,95.83 93.75,95.83H41.67V27.08Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M87.5,20.83H50C46.55,20.83 43.75,23.63 43.75,27.08V93.75H93.75V27.08C93.75,23.63 90.95,20.83 87.5,20.83ZM50,18.75C45.4,18.75 41.67,22.48 41.67,27.08V95.83H93.75C94.9,95.83 95.83,94.9 95.83,93.75V27.08C95.83,22.48 92.1,18.75 87.5,18.75H50Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#DBE5F6"
|
||||
android:pathData="M4.17,45.83C4.17,41.23 7.9,37.5 12.5,37.5H52.08C56.69,37.5 60.42,41.23 60.42,45.83V93.75C60.42,94.9 59.48,95.83 58.33,95.83H6.25C5.1,95.83 4.17,94.9 4.17,93.75V45.83Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M52.08,39.58H12.5C9.05,39.58 6.25,42.38 6.25,45.83L6.25,93.75H58.33V45.83C58.33,42.38 55.54,39.58 52.08,39.58ZM12.5,37.5C7.9,37.5 4.17,41.23 4.17,45.83V93.75C4.17,94.9 5.1,95.83 6.25,95.83H58.33C59.48,95.83 60.42,94.9 60.42,93.75V45.83C60.42,41.23 56.69,37.5 52.08,37.5H12.5Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M13.54,47.92C13.54,47.34 14.01,46.88 14.58,46.88H22.92C23.49,46.88 23.96,47.34 23.96,47.92V56.25C23.96,56.83 23.49,57.29 22.92,57.29H14.58C14.01,57.29 13.54,56.83 13.54,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M15.63,48.96V55.21H21.88V48.96H15.63ZM14.58,46.88C14.01,46.88 13.54,47.34 13.54,47.92V56.25C13.54,56.83 14.01,57.29 14.58,57.29H22.92C23.49,57.29 23.96,56.83 23.96,56.25V47.92C23.96,47.34 23.49,46.88 22.92,46.88H14.58Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M13.54,61.46C13.54,60.88 14.01,60.42 14.58,60.42H22.92C23.49,60.42 23.96,60.88 23.96,61.46V69.79C23.96,70.37 23.49,70.83 22.92,70.83H14.58C14.01,70.83 13.54,70.37 13.54,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M15.63,62.5V68.75H21.88V62.5H15.63ZM14.58,60.42C14.01,60.42 13.54,60.88 13.54,61.46V69.79C13.54,70.37 14.01,70.83 14.58,70.83H22.92C23.49,70.83 23.96,70.37 23.96,69.79V61.46C23.96,60.88 23.49,60.42 22.92,60.42H14.58Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M27.08,47.92C27.08,47.34 27.55,46.88 28.13,46.88H36.46C37.03,46.88 37.5,47.34 37.5,47.92V56.25C37.5,56.83 37.03,57.29 36.46,57.29H28.13C27.55,57.29 27.08,56.83 27.08,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M29.17,48.96V55.21H35.42V48.96H29.17ZM28.13,46.88C27.55,46.88 27.08,47.34 27.08,47.92V56.25C27.08,56.83 27.55,57.29 28.13,57.29H36.46C37.03,57.29 37.5,56.83 37.5,56.25V47.92C37.5,47.34 37.03,46.88 36.46,46.88H28.13Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M27.08,61.46C27.08,60.88 27.55,60.42 28.13,60.42H36.46C37.03,60.42 37.5,60.88 37.5,61.46V69.79C37.5,70.37 37.03,70.83 36.46,70.83H28.13C27.55,70.83 27.08,70.37 27.08,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M29.17,62.5V68.75H35.42V62.5H29.17ZM28.13,60.42C27.55,60.42 27.08,60.88 27.08,61.46V69.79C27.08,70.37 27.55,70.83 28.13,70.83H36.46C37.03,70.83 37.5,70.37 37.5,69.79V61.46C37.5,60.88 37.03,60.42 36.46,60.42H28.13Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M40.63,47.92C40.63,47.34 41.09,46.88 41.67,46.88H50C50.58,46.88 51.04,47.34 51.04,47.92V56.25C51.04,56.83 50.58,57.29 50,57.29H41.67C41.09,57.29 40.63,56.83 40.63,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42.71,48.96V55.21H48.96V48.96H42.71ZM41.67,46.88C41.09,46.88 40.63,47.34 40.63,47.92V56.25C40.63,56.83 41.09,57.29 41.67,57.29H50C50.58,57.29 51.04,56.83 51.04,56.25V47.92C51.04,47.34 50.58,46.88 50,46.88H41.67Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M48.96,28.13C48.96,27.55 49.42,27.08 50,27.08H56.25C56.83,27.08 57.29,27.55 57.29,28.13C57.29,28.7 56.83,29.17 56.25,29.17L50,29.17C49.42,29.17 48.96,28.7 48.96,28.13ZM60.42,27.08C59.84,27.08 59.38,27.55 59.38,28.13C59.38,28.7 59.84,29.17 60.42,29.17L66.67,29.17C67.24,29.17 67.71,28.7 67.71,28.13C67.71,27.55 67.24,27.08 66.67,27.08H60.42ZM69.79,28.13C69.79,27.55 70.26,27.08 70.83,27.08H77.08C77.66,27.08 78.13,27.55 78.13,28.13C78.13,28.7 77.66,29.17 77.08,29.17L70.83,29.17C70.26,29.17 69.79,28.7 69.79,28.13ZM80.21,28.13C80.21,27.55 80.67,27.08 81.25,27.08H87.5C88.08,27.08 88.54,27.55 88.54,28.13C88.54,28.7 88.08,29.17 87.5,29.17L81.25,29.17C80.67,29.17 80.21,28.7 80.21,28.13ZM80.21,34.38C80.21,33.8 80.67,33.33 81.25,33.33L87.5,33.33C88.08,33.33 88.54,33.8 88.54,34.38C88.54,34.95 88.08,35.42 87.5,35.42L81.25,35.42C80.67,35.42 80.21,34.95 80.21,34.38ZM81.25,39.58C80.67,39.58 80.21,40.05 80.21,40.63C80.21,41.2 80.67,41.67 81.25,41.67L87.5,41.67C88.08,41.67 88.54,41.2 88.54,40.63C88.54,40.05 88.08,39.58 87.5,39.58L81.25,39.58ZM80.21,46.88C80.21,46.3 80.67,45.83 81.25,45.83H87.5C88.08,45.83 88.54,46.3 88.54,46.88C88.54,47.45 88.08,47.92 87.5,47.92H81.25C80.67,47.92 80.21,47.45 80.21,46.88ZM81.25,52.08C80.67,52.08 80.21,52.55 80.21,53.13C80.21,53.7 80.67,54.17 81.25,54.17H87.5C88.08,54.17 88.54,53.7 88.54,53.13C88.54,52.55 88.08,52.08 87.5,52.08H81.25ZM70.83,33.33C70.26,33.33 69.79,33.8 69.79,34.38C69.79,34.95 70.26,35.42 70.83,35.42L77.08,35.42C77.66,35.42 78.13,34.95 78.13,34.38C78.13,33.8 77.66,33.33 77.08,33.33L70.83,33.33ZM69.79,40.63C69.79,40.05 70.26,39.58 70.83,39.58L77.08,39.58C77.66,39.58 78.13,40.05 78.13,40.63C78.13,41.2 77.66,41.67 77.08,41.67L70.83,41.67C70.26,41.67 69.79,41.2 69.79,40.63ZM70.83,45.83C70.26,45.83 69.79,46.3 69.79,46.88C69.79,47.45 70.26,47.92 70.83,47.92H77.08C77.66,47.92 78.13,47.45 78.13,46.88C78.13,46.3 77.66,45.83 77.08,45.83H70.83ZM69.79,53.13C69.79,52.55 70.26,52.08 70.83,52.08H77.08C77.66,52.08 78.13,52.55 78.13,53.13C78.13,53.7 77.66,54.17 77.08,54.17H70.83C70.26,54.17 69.79,53.7 69.79,53.13ZM60.42,33.33C59.84,33.33 59.38,33.8 59.38,34.38C59.38,34.95 59.84,35.42 60.42,35.42L66.67,35.42C67.24,35.42 67.71,34.95 67.71,34.38C67.71,33.8 67.24,33.33 66.67,33.33L60.42,33.33ZM48.96,34.38C48.96,33.8 49.42,33.33 50,33.33L56.25,33.33C56.83,33.33 57.29,33.8 57.29,34.38C57.29,34.95 56.83,35.42 56.25,35.42L50,35.42C49.42,35.42 48.96,34.95 48.96,34.38ZM60.42,39.58C59.84,39.58 59.38,40.05 59.38,40.63C59.38,41.2 59.84,41.67 60.42,41.67L66.67,41.67C67.24,41.67 67.71,41.2 67.71,40.63C67.71,40.05 67.24,39.58 66.67,39.58L60.42,39.58ZM59.38,46.88C59.38,46.3 59.84,45.83 60.42,45.83H66.67C67.24,45.83 67.71,46.3 67.71,46.88C67.71,47.45 67.24,47.92 66.67,47.92H60.42C59.84,47.92 59.38,47.45 59.38,46.88ZM60.42,52.08C59.84,52.08 59.38,52.55 59.38,53.13C59.38,53.7 59.84,54.17 60.42,54.17H66.67C67.24,54.17 67.71,53.7 67.71,53.13C67.71,52.55 67.24,52.08 66.67,52.08H60.42Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M40.63,61.46C40.63,60.88 41.09,60.42 41.67,60.42H50C50.58,60.42 51.04,60.88 51.04,61.46V69.79C51.04,70.37 50.58,70.83 50,70.83H41.67C41.09,70.83 40.63,70.37 40.63,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42.71,62.5V68.75H48.96V62.5H42.71ZM41.67,60.42C41.09,60.42 40.63,60.88 40.63,61.46V69.79C40.63,70.37 41.09,70.83 41.67,70.83H50C50.58,70.83 51.04,70.37 51.04,69.79V61.46C51.04,60.88 50.58,60.42 50,60.42H41.67Z" />
|
||||
<path
|
||||
android:name="accent"
|
||||
android:fillColor="#FFBF00"
|
||||
android:pathData="M21.88,86.46C21.88,84.73 23.27,83.33 25,83.33H39.58C41.31,83.33 42.71,84.73 42.71,86.46V96.88C42.71,98.6 41.31,100 39.58,100H25C23.27,100 21.88,98.6 21.88,96.88V86.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M39.58,85.42H25C24.42,85.42 23.96,85.88 23.96,86.46V96.88C23.96,97.45 24.42,97.92 25,97.92H39.58C40.16,97.92 40.63,97.45 40.63,96.88V86.46C40.63,85.88 40.16,85.42 39.58,85.42ZM25,83.33C23.27,83.33 21.88,84.73 21.88,86.46V96.88C21.88,98.6 23.27,100 25,100H39.58C41.31,100 42.71,98.6 42.71,96.88V86.46C42.71,84.73 41.31,83.33 39.58,83.33H25Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:pathData="M31.25,89.58C31.25,89.01 31.72,88.54 32.29,88.54V88.54C32.87,88.54 33.33,89.01 33.33,89.58V93.75C33.33,94.33 32.87,94.79 32.29,94.79V94.79C31.72,94.79 31.25,94.33 31.25,93.75V89.58Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M27.08,82.29C27.08,79.42 29.42,77.08 32.29,77.08C35.17,77.08 37.5,79.42 37.5,82.29V83.33H35.42V82.29C35.42,80.57 34.02,79.17 32.29,79.17C30.57,79.17 29.17,80.57 29.17,82.29V83.33H27.08V82.29Z" />
|
||||
</vector>
|
||||
@@ -1166,4 +1166,10 @@ Do you want to switch to this account?</string>
|
||||
<string name="loading_vault_data">Loading vault data…</string>
|
||||
<string name="resending">Resending</string>
|
||||
<string name="you_left_the_organization">You left the organization</string>
|
||||
<string name="transfer_items_to_org">Transfer items to %1$s</string>
|
||||
<string name="transfer_items_description">%1$s is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.</string>
|
||||
<string name="decline_and_leave">Decline and leave</string>
|
||||
<string name="why_am_i_seeing_this">Why am I seeing this?</string>
|
||||
<string name="migrating_items_to_x">Migrating items to %s</string>
|
||||
<string name="failed_to_migrate_items_to_x">Failed to migrate items to %s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user