BIT-1508: Add Pending Login Requests screen (#738)

This commit is contained in:
Caleb Derosier 2024-01-23 17:25:27 -07:00 committed by Álison Fernandes
parent 6a66d24dd1
commit 9a371843ee
16 changed files with 631 additions and 5 deletions

View file

@ -30,6 +30,7 @@ fun NavGraphBuilder.settingsGraph(
navController: NavController, navController: NavController,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit, onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
) { ) {
navigation( navigation(
startDestination = SETTINGS_ROUTE, startDestination = SETTINGS_ROUTE,
@ -51,6 +52,7 @@ fun NavGraphBuilder.settingsGraph(
accountSecurityDestination( accountSecurityDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests,
) )
appearanceDestination(onNavigateBack = { navController.popBackStack() }) appearanceDestination(onNavigateBack = { navController.popBackStack() })
autoFillDestination( autoFillDestination(

View file

@ -13,6 +13,7 @@ private const val ACCOUNT_SECURITY_ROUTE = "settings_account_security"
fun NavGraphBuilder.accountSecurityDestination( fun NavGraphBuilder.accountSecurityDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
) { ) {
composableWithPushTransitions( composableWithPushTransitions(
route = ACCOUNT_SECURITY_ROUTE, route = ACCOUNT_SECURITY_ROUTE,
@ -20,6 +21,7 @@ fun NavGraphBuilder.accountSecurityDestination(
AccountSecurityScreen( AccountSecurityScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests,
) )
} }
} }

View file

@ -70,6 +70,7 @@ private const val MINUTES_PER_HOUR = 60
fun AccountSecurityScreen( fun AccountSecurityScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
viewModel: AccountSecurityViewModel = hiltViewModel(), viewModel: AccountSecurityViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current, intentManager: IntentManager = LocalIntentManager.current,
permissionsManager: PermissionsManager = LocalPermissionsManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current,
@ -91,6 +92,8 @@ fun AccountSecurityScreen(
intentManager.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri()) intentManager.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri())
} }
AccountSecurityEvent.NavigateToPendingRequests -> onNavigateToPendingRequests()
is AccountSecurityEvent.ShowToast -> { is AccountSecurityEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
} }

View file

@ -144,8 +144,7 @@ class AccountSecurityViewModel @Inject constructor(
} }
private fun handlePendingLoginRequestsClick() { private fun handlePendingLoginRequestsClick() {
// TODO BIT-466: Implement pending login requests UI sendEvent(AccountSecurityEvent.NavigateToPendingRequests)
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
} }
private fun handleVaultTimeoutTypeSelect(action: AccountSecurityAction.VaultTimeoutTypeSelect) { private fun handleVaultTimeoutTypeSelect(action: AccountSecurityAction.VaultTimeoutTypeSelect) {
@ -296,6 +295,11 @@ sealed class AccountSecurityEvent {
*/ */
data object NavigateToFingerprintPhrase : AccountSecurityEvent() data object NavigateToFingerprintPhrase : AccountSecurityEvent()
/**
* Navigate to the Pending Login Requests screen.
*/
data object NavigateToPendingRequests : AccountSecurityEvent()
/** /**
* Displays a toast with the given [Text]. * Displays a toast with the given [Text].
*/ */

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val PENDING_REQUESTS_ROUTE = "pending_requests"
/**
* Add pending requests destinations to the nav graph.
*/
fun NavGraphBuilder.pendingRequestsDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = PENDING_REQUESTS_ROUTE,
) {
PendingRequestsScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the Pending Login Requests screen.
*/
fun NavController.navigateToPendingRequests(navOptions: NavOptions? = null) {
navigate(PENDING_REQUESTS_ROUTE, navOptions)
}

View file

@ -0,0 +1,267 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
/**
* Displays the pending login requests screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun PendingRequestsScreen(
viewModel: PendingRequestsViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
PendingRequestsEvent.NavigateBack -> onNavigateBack()
is PendingRequestsEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.pending_log_in_requests),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(PendingRequestsAction.CloseClick) }
},
)
},
) { innerPadding ->
when (val viewState = state.viewState) {
is PendingRequestsState.ViewState.Content -> {
PendingRequestsContent(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = viewState,
onDeclineAllRequestsClick = remember(viewModel) {
{
viewModel.trySendAction(
PendingRequestsAction.DeclineAllRequestsClick,
)
}
},
)
}
is PendingRequestsState.ViewState.Empty -> PendingRequestsEmpty(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
is PendingRequestsState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message.toString(resources),
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
is PendingRequestsState.ViewState.Loading -> BitwardenLoadingContent(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
/**
* Models the list content for the Pending Requests screen.
*/
@Composable
private fun PendingRequestsContent(
state: PendingRequestsState.ViewState.Content,
onDeclineAllRequestsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
LazyColumn(
Modifier.padding(bottom = 16.dp),
) {
items(state.requests) { request ->
PendingRequestItem(
fingerprintPhrase = request.fingerprintPhrase,
platform = request.platform,
timestamp = request.timestamp,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
BitwardenFilledTonalButtonWithIcon(
label = stringResource(id = R.string.decline_all_requests),
icon = painterResource(id = R.drawable.ic_trash),
onClick = onDeclineAllRequestsClick,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
/**
* Represents a pending request item to display in the list.
*/
@Composable
private fun PendingRequestItem(
fingerprintPhrase: String,
platform: String,
timestamp: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.Start,
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.fingerprint_phrase),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = fingerprintPhrase,
color = LocalNonMaterialColors.current.fingerprint,
style = LocalNonMaterialTypography.current.sensitiveInfoSmall,
textAlign = TextAlign.Start,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = platform,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier.padding(horizontal = 16.dp),
)
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
/**
* Models the empty state for the Pending Requests screen.
*/
@Composable
private fun PendingRequestsEmpty(
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
) {
Spacer(modifier = Modifier.height(16.dp))
Image(
painter = painterResource(id = R.drawable.ic_pending_requests),
contentDescription = null,
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.no_pending_requests),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
Spacer(modifier = Modifier.height(64.dp))
}
}

View file

@ -0,0 +1,132 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the pending login requests screen.
*/
@HiltViewModel
class PendingRequestsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>(
initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState(
viewState = PendingRequestsState.ViewState.Empty,
),
) {
init {
// TODO BIT-1291: make /auth-requests call
}
override fun handleAction(action: PendingRequestsAction) {
when (action) {
PendingRequestsAction.CloseClick -> handleCloseClicked()
PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked()
}
}
private fun handleCloseClicked() {
sendEvent(PendingRequestsEvent.NavigateBack)
}
private fun handleDeclineAllRequestsClicked() {
sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText()))
}
}
/**
* Models state for the Pending Login Requests screen.
*/
@Parcelize
data class PendingRequestsState(
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [PendingRequestsScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* Content state for the [PendingRequestsScreen] listing pending request items.
*/
@Parcelize
data class Content(
val requests: List<PendingLoginRequest>,
) : ViewState() {
/**
* Models the data for a pending login request.
*/
@Parcelize
data class PendingLoginRequest(
val fingerprintPhrase: String,
val platform: String,
val timestamp: String,
) : Parcelable
}
/**
* Represents the state wherein there are no pending login requests.
*/
@Parcelize
data object Empty : ViewState()
/**
* Represents a state where the [PendingRequestsScreen] is unable to display data due to an
* error retrieving it.
*
* @property message The message to display on the error screen.
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
/**
* Loading state for the [PendingRequestsScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
}
}
/**
* Models events for the delete account screen.
*/
sealed class PendingRequestsEvent {
/**
* Navigates back.
*/
data object NavigateBack : PendingRequestsEvent()
/**
* Displays the [message] in a toast.
*/
data class ShowToast(
val message: Text,
) : PendingRequestsEvent()
}
/**
* Models actions for the delete account screen.
*/
sealed class PendingRequestsAction {
/**
* The user has clicked the close button.
*/
data object CloseClick : PendingRequestsAction()
/**
* The user has clicked to deny all login requests.
*/
data object DeclineAllRequestsClick : PendingRequestsAction()
}

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
@ -74,9 +76,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToAddSend = { navController.navigateToAddSend(AddSendType.AddItem) }, onNavigateToAddSend = { navController.navigateToAddSend(AddSendType.AddItem) },
onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) }, onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) },
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() }, onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
onNavigateToPendingRequests = { navController.navigateToPendingRequests() },
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() }, onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
) )
deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
pendingRequestsDestination(onNavigateBack = { navController.popBackStack() })
vaultAddEditDestination( vaultAddEditDestination(
onNavigateToQrCodeScanScreen = { onNavigateToQrCodeScanScreen = {
navController.navigateToQrCodeScanScreen() navController.navigateToQrCodeScanScreen()

View file

@ -32,6 +32,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToEditSend: (sendItemId: String) -> Unit, onNavigateToEditSend: (sendItemId: String) -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit, onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
) { ) {
composableWithStayTransitions( composableWithStayTransitions(
@ -47,6 +48,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToEditSend = onNavigateToEditSend, onNavigateToEditSend = onNavigateToEditSend,
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToFolders = onNavigateToFolders, onNavigateToFolders = onNavigateToFolders,
onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToPasswordHistory = onNavigateToPasswordHistory, onNavigateToPasswordHistory = onNavigateToPasswordHistory,
) )
} }

View file

@ -81,6 +81,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToEditSend: (sendItemId: String) -> Unit, onNavigateToEditSend: (sendItemId: String) -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToFolders: () -> Unit, onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
) { ) {
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
@ -125,6 +126,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToEditSend = onNavigateToEditSend, onNavigateToEditSend = onNavigateToEditSend,
navigateToDeleteAccount = onNavigateToDeleteAccount, navigateToDeleteAccount = onNavigateToDeleteAccount,
navigateToFolders = onNavigateToFolders, navigateToFolders = onNavigateToFolders,
navigateToPendingRequests = onNavigateToPendingRequests,
navigateToPasswordHistory = onNavigateToPasswordHistory, navigateToPasswordHistory = onNavigateToPasswordHistory,
generatorTabClickedAction = { generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
@ -162,6 +164,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToEditSend: (sendItemId: String) -> Unit, onNavigateToEditSend: (sendItemId: String) -> Unit,
navigateToDeleteAccount: () -> Unit, navigateToDeleteAccount: () -> Unit,
navigateToFolders: () -> Unit, navigateToFolders: () -> Unit,
navigateToPendingRequests: () -> Unit,
navigateToPasswordHistory: () -> Unit, navigateToPasswordHistory: () -> Unit,
) { ) {
var shouldDimNavBar by remember { mutableStateOf(false) } var shouldDimNavBar by remember { mutableStateOf(false) }
@ -235,6 +238,7 @@ private fun VaultUnlockedNavBarScaffold(
navController = navController, navController = navController,
onNavigateToDeleteAccount = navigateToDeleteAccount, onNavigateToDeleteAccount = navigateToDeleteAccount,
onNavigateToFolders = navigateToFolders, onNavigateToFolders = navigateToFolders,
onNavigateToPendingRequests = navigateToPendingRequests,
) )
} }
} }

View file

@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="198dp"
android:height="118dp"
android:viewportHeight="118"
android:viewportWidth="198">
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M33.94,31.88H9.98C8.39,31.88 6.87,32.51 5.74,33.63C4.62,34.76 3.99,36.28 3.99,37.87V97.77C3.99,99.36 4.62,100.88 5.74,102.01C6.87,103.13 8.39,103.76 9.98,103.76H33.94C35.53,103.76 37.05,103.13 38.18,102.01C39.3,100.88 39.93,99.36 39.93,97.77V37.87C39.93,36.28 39.3,34.76 38.18,33.63C37.05,32.51 35.53,31.88 33.94,31.88ZM9.98,27.89C7.33,27.89 4.79,28.94 2.92,30.81C1.05,32.68 -0,35.22 -0,37.87V97.77C-0,100.42 1.05,102.96 2.92,104.83C4.79,106.7 7.33,107.75 9.98,107.75H33.94C36.59,107.75 39.13,106.7 41,104.83C42.87,102.96 43.92,100.42 43.92,97.77V37.87C43.92,35.22 42.87,32.68 41,30.81C39.13,28.94 36.59,27.89 33.94,27.89H9.98Z" />
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M20.33,36.67C20.33,36.41 20.44,36.15 20.63,35.97C20.81,35.78 21.07,35.67 21.33,35.67H22.57C22.83,35.67 23.09,35.78 23.28,35.97C23.46,36.15 23.57,36.41 23.57,36.67C23.57,36.94 23.46,37.19 23.28,37.38C23.09,37.57 22.83,37.67 22.57,37.67H21.33C21.07,37.67 20.81,37.57 20.63,37.38C20.44,37.19 20.33,36.94 20.33,36.67ZM67.88,108.75C67.88,108.49 67.99,108.23 68.18,108.05C68.36,107.86 68.62,107.75 68.88,107.75H126.79C127.05,107.75 127.31,107.86 127.49,108.05C127.68,108.23 127.79,108.49 127.79,108.75C127.79,109.02 127.68,109.27 127.49,109.46C127.31,109.65 127.05,109.75 126.79,109.75H68.88C68.62,109.75 68.36,109.65 68.18,109.46C67.99,109.27 67.88,109.02 67.88,108.75Z" />
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M87.59,108.75V91.02H89.59V108.75H87.59ZM107.53,108.75V91.02H109.53V108.75H107.53Z" />
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M26.95,13.91C26.95,10.2 28.42,6.65 31.05,4.03C33.67,1.41 37.22,-0.07 40.93,-0.07H156.74C160.45,-0.07 164,1.41 166.62,4.03C169.24,6.65 170.71,10.2 170.71,13.91V19.9H166.72V13.91C166.72,11.26 165.67,8.72 163.8,6.85C161.93,4.98 159.39,3.93 156.74,3.93H40.93C38.28,3.93 35.74,4.98 33.87,6.85C32,8.72 30.95,11.26 30.95,13.91V29.88H26.95V13.91ZM41.93,87.79H126.79V91.78H41.93V87.79Z" />
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M34.94,14.91C34.94,13.05 35.68,11.28 36.99,9.97C38.3,8.66 40.07,7.92 41.93,7.92H155.74C157.59,7.92 159.37,8.66 160.68,9.97C161.99,11.28 162.73,13.05 162.73,14.91V19.9H160.73V14.91C160.73,13.58 160.21,12.31 159.27,11.38C158.33,10.44 157.06,9.92 155.74,9.92H41.93C40.6,9.92 39.33,10.44 38.4,11.38C37.46,12.31 36.94,13.58 36.94,14.91V29.88H34.94V14.91ZM41.93,81.8H126.79V83.79H41.93V81.8Z" />
<path
android:fillColor="#8E8E93"
android:fillType="evenOdd"
android:pathData="M124.79,27.89C124.79,25.24 125.84,22.7 127.71,20.83C129.59,18.95 132.13,17.9 134.77,17.9H187.69C190.34,17.9 192.87,18.95 194.75,20.83C196.62,22.7 197.67,25.24 197.67,27.89V107.75C197.67,110.4 196.62,112.94 194.75,114.81C192.87,116.69 190.34,117.74 187.69,117.74H134.77C132.13,117.74 129.59,116.69 127.71,114.81C125.84,112.94 124.79,110.4 124.79,107.75V27.89ZM134.77,21.9C133.19,21.9 131.66,22.53 130.54,23.65C129.41,24.77 128.79,26.3 128.79,27.89V107.75C128.79,109.34 129.41,110.87 130.54,111.99C131.66,113.11 133.19,113.75 134.77,113.75H187.69C189.28,113.75 190.8,113.11 191.92,111.99C193.05,110.87 193.68,109.34 193.68,107.75V27.89C193.68,26.3 193.05,24.77 191.92,23.65C190.8,22.53 189.28,21.9 187.69,21.9H134.77Z" />
<path
android:fillColor="#8E8E93"
android:pathData="M163.73,108.75C163.73,109.28 163.52,109.79 163.14,110.17C162.77,110.54 162.26,110.75 161.73,110.75C161.2,110.75 160.69,110.54 160.32,110.17C159.94,109.79 159.73,109.28 159.73,108.75C159.73,108.22 159.94,107.72 160.32,107.34C160.69,106.97 161.2,106.76 161.73,106.76C162.26,106.76 162.77,106.97 163.14,107.34C163.52,107.72 163.73,108.22 163.73,108.75Z" />
</vector>

View file

@ -41,6 +41,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onNavigateToDeleteAccountCalled = false private var onNavigateToDeleteAccountCalled = false
private var onNavigateToPendingRequestsCalled = false
private val intentManager = mockk<IntentManager> { private val intentManager = mockk<IntentManager> {
every { launchUri(any()) } just runs every { launchUri(any()) } just runs
@ -61,6 +62,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
AccountSecurityScreen( AccountSecurityScreen(
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true }, onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true },
onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true },
viewModel = viewModel, viewModel = viewModel,
intentManager = intentManager, intentManager = intentManager,
permissionsManager = permissionsManager, permissionsManager = permissionsManager,
@ -1100,11 +1102,17 @@ class AccountSecurityScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `on NavigateToDeleteAccount should call onNavigateToDeleteAccountCalled`() { fun `on NavigateToDeleteAccount should call onNavigateToDeleteAccount`() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToDeleteAccount) mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToDeleteAccount)
assertTrue(onNavigateToDeleteAccountCalled) assertTrue(onNavigateToDeleteAccountCalled)
} }
@Test
fun `on NavigateToPendingRequests should call onNavigateToPendingRequests`() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToPendingRequests)
assertTrue(onNavigateToPendingRequestsCalled)
}
@Test @Test
fun `confirm dialog be shown or hidden according to the state`() { fun `confirm dialog be shown or hidden according to the state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist() composeTestRule.onNode(isDialog()).assertDoesNotExist()

View file

@ -115,12 +115,12 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `on PendingLoginRequestsClick should emit ShowToast`() = runTest { fun `on PendingLoginRequestsClick should emit NavigateToPendingRequests`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick)
assertEquals( assertEquals(
AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), AccountSecurityEvent.NavigateToPendingRequests,
awaitItem(), awaitItem(),
) )
} }

View file

@ -0,0 +1,75 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class PendingRequestsScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<PendingRequestsEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<PendingRequestsViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
PendingRequestsScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(PendingRequestsEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on DeclineAllRequestsClick should send DeclineAllRequestsClick`() = runTest {
// set content so the Decline all requests button appears
mutableStateFlow.tryEmit(
PendingRequestsState(
viewState = PendingRequestsState.ViewState.Content(
requests = listOf(
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "pantry-overdue-survive-sleep-jab",
platform = "iOS",
timestamp = "8/24/2023 11:11 AM",
),
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "erupt-anew-matchbook-disk-student",
platform = "Android",
timestamp = "8/21/2023 9:43 AM",
),
),
),
),
)
composeTestRule.onNodeWithText("Decline all requests").performClick()
verify {
viewModel.trySendAction(PendingRequestsAction.DeclineAllRequestsClick)
}
}
companion object {
val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
viewState = PendingRequestsState.ViewState.Empty,
)
}
}

View file

@ -0,0 +1,61 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class PendingRequestsViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
viewState = PendingRequestsState.ViewState.Loading,
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `on CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(PendingRequestsAction.CloseClick)
assertEquals(PendingRequestsEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on DeclineAllRequestsClick should send ShowToast event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(PendingRequestsAction.DeclineAllRequestsClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
PendingRequestsEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
}
private fun createViewModel(
state: PendingRequestsState? = DEFAULT_STATE,
): PendingRequestsViewModel = PendingRequestsViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
companion object {
val DEFAULT_STATE: PendingRequestsState = PendingRequestsState(
viewState = PendingRequestsState.ViewState.Empty,
)
}
}

View file

@ -47,6 +47,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToDeleteAccount = {}, onNavigateToDeleteAccount = {},
onNavigateToFolders = {}, onNavigateToFolders = {},
onNavigateToPasswordHistory = {}, onNavigateToPasswordHistory = {},
onNavigateToPendingRequests = {},
onNavigateToSearchVault = {}, onNavigateToSearchVault = {},
onNavigateToSearchSend = {}, onNavigateToSearchSend = {},
) )