From 9a371843ee076c4c4a78d55197c150493909d7cf Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:25:27 -0700 Subject: [PATCH] BIT-1508: Add Pending Login Requests screen (#738) --- .../feature/settings/SettingsNavigation.kt | 2 + .../AccountSecurityNavigation.kt | 2 + .../accountsecurity/AccountSecurityScreen.kt | 3 + .../AccountSecurityViewModel.kt | 8 +- .../PendingRequestsNavigation.kt | 28 ++ .../pendingrequests/PendingRequestsScreen.kt | 267 ++++++++++++++++++ .../PendingRequestsViewModel.kt | 132 +++++++++ .../vaultunlocked/VaultUnlockedNavigation.kt | 4 + .../VaultUnlockedNavBarNavigation.kt | 2 + .../VaultUnlockedNavBarScreen.kt | 4 + .../main/res/drawable/ic_pending_requests.xml | 33 +++ .../AccountSecurityScreenTest.kt | 10 +- .../AccountSecurityViewModelTest.kt | 4 +- .../PendingRequestsScreenTest.kt | 75 +++++ .../PendingRequestsViewModelTest.kt | 61 ++++ .../VaultUnlockedNavBarScreenTest.kt | 1 + 16 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_pending_requests.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index 48cabe4c3..84ae3c204 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -30,6 +30,7 @@ fun NavGraphBuilder.settingsGraph( navController: NavController, onNavigateToDeleteAccount: () -> Unit, onNavigateToFolders: () -> Unit, + onNavigateToPendingRequests: () -> Unit, ) { navigation( startDestination = SETTINGS_ROUTE, @@ -51,6 +52,7 @@ fun NavGraphBuilder.settingsGraph( accountSecurityDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToDeleteAccount = onNavigateToDeleteAccount, + onNavigateToPendingRequests = onNavigateToPendingRequests, ) appearanceDestination(onNavigateBack = { navController.popBackStack() }) autoFillDestination( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt index 6b496aae4..caf6ee3b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt @@ -13,6 +13,7 @@ private const val ACCOUNT_SECURITY_ROUTE = "settings_account_security" fun NavGraphBuilder.accountSecurityDestination( onNavigateBack: () -> Unit, onNavigateToDeleteAccount: () -> Unit, + onNavigateToPendingRequests: () -> Unit, ) { composableWithPushTransitions( route = ACCOUNT_SECURITY_ROUTE, @@ -20,6 +21,7 @@ fun NavGraphBuilder.accountSecurityDestination( AccountSecurityScreen( onNavigateBack = onNavigateBack, onNavigateToDeleteAccount = onNavigateToDeleteAccount, + onNavigateToPendingRequests = onNavigateToPendingRequests, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 8a906f81b..8ad37f4a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -70,6 +70,7 @@ private const val MINUTES_PER_HOUR = 60 fun AccountSecurityScreen( onNavigateBack: () -> Unit, onNavigateToDeleteAccount: () -> Unit, + onNavigateToPendingRequests: () -> Unit, viewModel: AccountSecurityViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current, @@ -91,6 +92,8 @@ fun AccountSecurityScreen( intentManager.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri()) } + AccountSecurityEvent.NavigateToPendingRequests -> onNavigateToPendingRequests() + is AccountSecurityEvent.ShowToast -> { Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index 9e1e50081..b3c63b8c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -144,8 +144,7 @@ class AccountSecurityViewModel @Inject constructor( } private fun handlePendingLoginRequestsClick() { - // TODO BIT-466: Implement pending login requests UI - sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + sendEvent(AccountSecurityEvent.NavigateToPendingRequests) } private fun handleVaultTimeoutTypeSelect(action: AccountSecurityAction.VaultTimeoutTypeSelect) { @@ -296,6 +295,11 @@ sealed class AccountSecurityEvent { */ data object NavigateToFingerprintPhrase : AccountSecurityEvent() + /** + * Navigate to the Pending Login Requests screen. + */ + data object NavigateToPendingRequests : AccountSecurityEvent() + /** * Displays a toast with the given [Text]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt new file mode 100644 index 000000000..10fc8cfd8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt new file mode 100644 index 000000000..a481f7589 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt new file mode 100644 index 000000000..32bbfcf8e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -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( + 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, + ) : 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 3b9bcaac3..05046142e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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.settings.accountsecurity.deleteaccount.deleteAccountDestination 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.navigateToFolders 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) }, onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) }, onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() }, + onNavigateToPendingRequests = { navController.navigateToPendingRequests() }, onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() }, ) deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) + pendingRequestsDestination(onNavigateBack = { navController.popBackStack() }) vaultAddEditDestination( onNavigateToQrCodeScanScreen = { navController.navigateToQrCodeScanScreen() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 1e8c981d4..82dca552d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -32,6 +32,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToEditSend: (sendItemId: String) -> Unit, onNavigateToDeleteAccount: () -> Unit, onNavigateToFolders: () -> Unit, + onNavigateToPendingRequests: () -> Unit, onNavigateToPasswordHistory: () -> Unit, ) { composableWithStayTransitions( @@ -47,6 +48,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToEditSend = onNavigateToEditSend, onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToFolders = onNavigateToFolders, + onNavigateToPendingRequests = onNavigateToPendingRequests, onNavigateToPasswordHistory = onNavigateToPasswordHistory, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 162f359f1..df9c7e9cf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -81,6 +81,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToEditSend: (sendItemId: String) -> Unit, onNavigateToDeleteAccount: () -> Unit, onNavigateToFolders: () -> Unit, + onNavigateToPendingRequests: () -> Unit, onNavigateToPasswordHistory: () -> Unit, ) { EventsEffect(viewModel = viewModel) { event -> @@ -125,6 +126,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToEditSend = onNavigateToEditSend, navigateToDeleteAccount = onNavigateToDeleteAccount, navigateToFolders = onNavigateToFolders, + navigateToPendingRequests = onNavigateToPendingRequests, navigateToPasswordHistory = onNavigateToPasswordHistory, generatorTabClickedAction = { viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) @@ -162,6 +164,7 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToEditSend: (sendItemId: String) -> Unit, navigateToDeleteAccount: () -> Unit, navigateToFolders: () -> Unit, + navigateToPendingRequests: () -> Unit, navigateToPasswordHistory: () -> Unit, ) { var shouldDimNavBar by remember { mutableStateOf(false) } @@ -235,6 +238,7 @@ private fun VaultUnlockedNavBarScaffold( navController = navController, onNavigateToDeleteAccount = navigateToDeleteAccount, onNavigateToFolders = navigateToFolders, + onNavigateToPendingRequests = navigateToPendingRequests, ) } } diff --git a/app/src/main/res/drawable/ic_pending_requests.xml b/app/src/main/res/drawable/ic_pending_requests.xml new file mode 100644 index 000000000..1bf251555 --- /dev/null +++ b/app/src/main/res/drawable/ic_pending_requests.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index d3f7101db..30afd0442 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -41,6 +41,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false private var onNavigateToDeleteAccountCalled = false + private var onNavigateToPendingRequestsCalled = false private val intentManager = mockk { every { launchUri(any()) } just runs @@ -61,6 +62,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { AccountSecurityScreen( onNavigateBack = { onNavigateBackCalled = true }, onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true }, + onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true }, viewModel = viewModel, intentManager = intentManager, permissionsManager = permissionsManager, @@ -1100,11 +1102,17 @@ class AccountSecurityScreenTest : BaseComposeTest() { } @Test - fun `on NavigateToDeleteAccount should call onNavigateToDeleteAccountCalled`() { + fun `on NavigateToDeleteAccount should call onNavigateToDeleteAccount`() { mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToDeleteAccount) assertTrue(onNavigateToDeleteAccountCalled) } + @Test + fun `on NavigateToPendingRequests should call onNavigateToPendingRequests`() { + mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToPendingRequests) + assertTrue(onNavigateToPendingRequestsCalled) + } + @Test fun `confirm dialog be shown or hidden according to the state`() { composeTestRule.onNode(isDialog()).assertDoesNotExist() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 3173f2a93..f567b345b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -115,12 +115,12 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } @Test - fun `on PendingLoginRequestsClick should emit ShowToast`() = runTest { + fun `on PendingLoginRequestsClick should emit NavigateToPendingRequests`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) assertEquals( - AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + AccountSecurityEvent.NavigateToPendingRequests, awaitItem(), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt new file mode 100644 index 000000000..93c6fb5d9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt new file mode 100644 index 000000000..58adf532d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index 297d5c349..44b6d3abc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -47,6 +47,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { onNavigateToDeleteAccount = {}, onNavigateToFolders = {}, onNavigateToPasswordHistory = {}, + onNavigateToPendingRequests = {}, onNavigateToSearchVault = {}, onNavigateToSearchSend = {}, )