From 830112c070a90d60784e00fd2337edf4a77f08c7 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Fri, 10 Nov 2023 11:54:30 -0600 Subject: [PATCH] BIT-852: Add account switcher UI (#235) --- .../auth/repository/model/AccountSummary.kt | 43 +++ .../ui/platform/base/util/StringExtensions.kt | 12 + .../components/BitwardenAccountSwitcher.kt | 263 ++++++++++++++++++ .../components/BitwardenAnimatedScrim.kt | 45 +++ .../VaultUnlockedNavBarScreen.kt | 156 +++++++---- .../ui/vault/feature/vault/VaultNavigation.kt | 2 + .../ui/vault/feature/vault/VaultScreen.kt | 107 +++++-- .../ui/vault/feature/vault/VaultViewModel.kt | 89 +++++- .../vault/util/AccountSummaryExtensions.kt | 43 +++ app/src/main/res/drawable/ic_locked.xml | 13 + app/src/main/res/drawable/ic_unlocked.xml | 13 + .../ui/vault/feature/vault/VaultScreenTest.kt | 68 ++++- .../vault/feature/vault/VaultViewModelTest.kt | 54 ++++ .../util/AccountSummaryExtensionsTest.kt | 88 ++++++ 14 files changed, 914 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AccountSummary.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAccountSwitcher.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAnimatedScrim.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensions.kt create mode 100644 app/src/main/res/drawable/ic_locked.xml create mode 100644 app/src/main/res/drawable/ic_unlocked.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AccountSummary.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AccountSummary.kt new file mode 100644 index 0000000000..2724ec21b3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AccountSummary.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Summary information about a user's account. + * + * @property userId The ID of the user. + * @property name The full name of the user. + * @property email The email of the user. + * @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format. + * @property status The current status of the user's account locally. + */ +@Parcelize +data class AccountSummary( + val userId: String, + val name: String, + val email: String, + val avatarColorHex: String, + val status: Status, +) : Parcelable { + + /** + * Describes the status of the given account. + */ + enum class Status { + /** + * The account is currently the active one. + */ + ACTIVE, + + /** + * The account is currently locked. + */ + LOCKED, + + /** + * The account is currently unlocked. + */ + UNLOCKED, + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt index 88109a5b2b..532ea019e9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -1,9 +1,12 @@ package com.x8bit.bitwarden.ui.platform.base.util +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.core.graphics.toColorInt import java.net.URI +import java.util.Locale /** * Whether or not string is a valid email address. @@ -25,6 +28,15 @@ fun String.isValidUri(): Boolean = false } +/** + * Returns the given [String] in a lowercase form using the primary [Locale] from the current + * context. + */ +@Composable +fun String.lowercaseWithCurrentLocal(): String { + return lowercase(LocalContext.current.resources.configuration.locales[0]) +} + /** * Returns the [String] as an [AnnotatedString]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAccountSwitcher.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAccountSwitcher.kt new file mode 100644 index 0000000000..49cd6134da --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAccountSwitcher.kt @@ -0,0 +1,263 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import androidx.core.graphics.toColorInt +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary +import com.x8bit.bitwarden.ui.platform.base.util.lowercaseWithCurrentLocal +import com.x8bit.bitwarden.ui.platform.base.util.toUnscaledTextUnit +import com.x8bit.bitwarden.ui.vault.feature.vault.util.iconRes +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import com.x8bit.bitwarden.ui.vault.feature.vault.util.supportingTextResOrNull +import kotlinx.collections.immutable.ImmutableList + +/** + * An account switcher that will slide down inside whatever parent is it placed in and add a + * a scrim via a [BitwardenAnimatedScrim] to all content below it (but not above it). Additional + * [BitwardenAnimatedScrim] may be manually placed over other components that might not be covered + * by the internal one. + * + * Note that this is intended to be used in conjunction with screens containing a top app bar but + * should be placed with the screen's content and not with the bar itself. + * + * @param isVisible Whether or not this component is visible. Changing this value will animate the + * component in or out of view. + * @param accountSummaries The accounts to display in the switcher. + * @param onAccountSummaryClick A callback when an account is clicked. + * @param onAddAccountClick A callback when the Add Account row is clicked. + * @param onDismissRequest A callback when the component requests to be dismissed. This is triggered + * whenever the user clicks on the scrim or any of the switcher items. + * @param modifier A [Modifier] for the composable. + * @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in + * sync with the associated app bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenAccountSwitcher( + isVisible: Boolean, + accountSummaries: ImmutableList, + onAccountSummaryClick: (AccountSummary) -> Unit, + onAddAccountClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + topAppBarScrollBehavior: TopAppBarScrollBehavior, +) { + // Match the color of the switcher the different states of the app bar. + val contentBackgroundColor = + lerp( + start = MaterialTheme.colorScheme.surface, + stop = MaterialTheme.colorScheme.surfaceContainer, + fraction = topAppBarScrollBehavior.state.collapsedFraction, + ) + Box(modifier = modifier) { + BitwardenAnimatedScrim( + isVisible = isVisible, + onClick = onDismissRequest, + modifier = Modifier + .fillMaxSize(), + ) + AnimatedAccountSwitcher( + isVisible = isVisible, + accountSummaries = accountSummaries, + onAccountSummaryClick = { + onDismissRequest() + onAccountSummaryClick(it) + }, + onAddAccountClick = { + onDismissRequest() + onAddAccountClick() + }, + contentBackgroundColor = contentBackgroundColor, + modifier = Modifier + .fillMaxWidth(), + ) + } +} + +@Composable +private fun AnimatedAccountSwitcher( + isVisible: Boolean, + accountSummaries: ImmutableList, + onAccountSummaryClick: (AccountSummary) -> Unit, + onAddAccountClick: () -> Unit, + modifier: Modifier = Modifier, + contentBackgroundColor: Color, +) { + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + ) { + LazyColumn( + modifier = modifier + // To prevent going all the way up to the bottom of the screen, we'll add some small + // bottom padding. + .padding(bottom = 24.dp) + .background(contentBackgroundColor), + ) { + items(accountSummaries) { accountSummary -> + AccountSummaryItem( + accountSummary = accountSummary, + onClick = onAccountSummaryClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + item { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + item { + AddAccountItem( + onClick = onAddAccountClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } +} + +@Composable +private fun AccountSummaryItem( + accountSummary: AccountSummary, + onClick: (AccountSummary) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = { onClick(accountSummary) }, + ) + .padding(vertical = 8.dp) + .then(modifier), + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_account_initials_container), + contentDescription = null, + tint = Color(accountSummary.avatarColorHex.toColorInt()), + modifier = Modifier.size(40.dp), + ) + + Text( + text = accountSummary.initials, + style = MaterialTheme.typography.titleMedium + // Do not allow scaling + .copy(fontSize = 16.dp.toUnscaledTextUnit()), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.clearAndSetSemantics { }, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = accountSummary.email, + style = MaterialTheme.typography.bodyLarge, + ) + + accountSummary.supportingTextResOrNull?.let { supportingTextResId -> + Text( + text = stringResource(id = supportingTextResId).lowercaseWithCurrentLocal(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Icon( + painter = painterResource(id = accountSummary.iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .size(24.dp), + ) + + Spacer(modifier = Modifier.width(8.dp)) + } +} + +@Composable +private fun AddAccountItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .padding(vertical = 8.dp) + .then(modifier), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(vertical = 8.dp) + .size(24.dp), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(id = R.string.add_account), + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAnimatedScrim.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAnimatedScrim.kt new file mode 100644 index 0000000000..e784eb78a4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenAnimatedScrim.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * A scrim that animates its visibility. + * + * @param isVisible Whether or not the scrim should be visible. This controls the animation. + * @param onClick A callback that is triggered when the scrim is clicked. No ripple will be + * performed. + * @param modifier A [Modifier] for the scrim's content. + */ +@Composable +fun BitwardenAnimatedScrim( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = modifier + .background(Color.Black.copy(alpha = 0.40f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + // Clear the ripple + indication = null, + onClick = onClick, + ), + ) + } +} 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 b04178cf6e..9850733a75 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 @@ -1,9 +1,12 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar import android.os.Parcelable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding @@ -17,7 +20,12 @@ import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -32,6 +40,8 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.max +import com.x8bit.bitwarden.ui.platform.base.util.toDp +import com.x8bit.bitwarden.ui.platform.components.BitwardenAnimatedScrim import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph @@ -113,66 +123,36 @@ private fun VaultUnlockedNavBarScaffold( navigateToVaultAddItem: () -> Unit, navigateToNewSend: () -> Unit, ) { + var shouldDimNavBar by remember { mutableStateOf(false) } + // This scaffold will host screens that contain top bars while not hosting one itself. // We need to ignore the status bar insets here and let the content screens handle // it themselves. BitwardenScaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars), bottomBar = { - BottomAppBar( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ) { - val destinations = listOf( - VaultUnlockedNavBarTab.Vault, - VaultUnlockedNavBarTab.Send, - VaultUnlockedNavBarTab.Generator, - VaultUnlockedNavBarTab.Settings, + Box { + var appBarHeightPx by remember { mutableIntStateOf(0) } + VaultBottomAppBar( + navController = navController, + vaultTabClickedAction = vaultTabClickedAction, + sendTabClickedAction = sendTabClickedAction, + generatorTabClickedAction = generatorTabClickedAction, + settingsTabClickedAction = settingsTabClickedAction, + modifier = Modifier + .onGloballyPositioned { + appBarHeightPx = it.size.height + }, + ) + BitwardenAnimatedScrim( + isVisible = shouldDimNavBar, + onClick = { + // Do nothing + }, + modifier = Modifier + .fillMaxWidth() + .height(appBarHeightPx.toDp()), ) - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - destinations.forEach { destination -> - - val isSelected = currentDestination?.hierarchy?.any { - it.route == destination.route - } == true - - NavigationBarItem( - icon = { - Icon( - painter = painterResource( - id = if (isSelected) { - destination.iconResSelected - } else { - destination.iconRes - }, - ), - contentDescription = stringResource( - id = destination.contentDescriptionRes, - ), - tint = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - }, - label = { - Text(text = stringResource(id = destination.labelRes)) - }, - selected = isSelected, - onClick = { - when (destination) { - VaultUnlockedNavBarTab.Vault -> vaultTabClickedAction() - VaultUnlockedNavBarTab.Send -> sendTabClickedAction() - VaultUnlockedNavBarTab.Generator -> generatorTabClickedAction() - VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction() - } - }, - colors = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.secondaryContainer, - ), - ) - } } }, ) { innerPadding -> @@ -195,6 +175,9 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToVaultAddItemScreen = { navigateToVaultAddItem() }, + onDimBottomNavBarRequest = { shouldDim -> + shouldDimNavBar = shouldDim + }, ) sendGraph(onNavigateToNewSend = navigateToNewSend) generatorDestination() @@ -203,6 +186,73 @@ private fun VaultUnlockedNavBarScaffold( } } +@Composable +private fun VaultBottomAppBar( + navController: NavHostController, + vaultTabClickedAction: () -> Unit, + sendTabClickedAction: () -> Unit, + generatorTabClickedAction: () -> Unit, + settingsTabClickedAction: () -> Unit, + modifier: Modifier = Modifier, +) { + BottomAppBar( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier, + ) { + val destinations = listOf( + VaultUnlockedNavBarTab.Vault, + VaultUnlockedNavBarTab.Send, + VaultUnlockedNavBarTab.Generator, + VaultUnlockedNavBarTab.Settings, + ) + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + destinations.forEach { destination -> + + val isSelected = currentDestination?.hierarchy?.any { + it.route == destination.route + } == true + + NavigationBarItem( + icon = { + Icon( + painter = painterResource( + id = if (isSelected) { + destination.iconResSelected + } else { + destination.iconRes + }, + ), + contentDescription = stringResource( + id = destination.contentDescriptionRes, + ), + tint = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + label = { + Text(text = stringResource(id = destination.labelRes)) + }, + selected = isSelected, + onClick = { + when (destination) { + VaultUnlockedNavBarTab.Vault -> vaultTabClickedAction() + VaultUnlockedNavBarTab.Send -> sendTabClickedAction() + VaultUnlockedNavBarTab.Generator -> generatorTabClickedAction() + VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction() + } + }, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + } + } +} + /** * Represents the different tabs available in the navigation bar * for the unlocked portion of the vault. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index aa79631779..a697a9aba3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -12,10 +12,12 @@ const val VAULT_ROUTE: String = "vault" */ fun NavGraphBuilder.vaultDestination( onNavigateToVaultAddItemScreen: () -> Unit, + onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, ) { composable(VAULT_ROUTE) { VaultScreen( onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen, + onDimBottomNavBarRequest = onDimBottomNavBarRequest, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index e2e8de8978..8ef0068f8a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -2,8 +2,11 @@ package com.x8bit.bitwarden.ui.vault.feature.vault import android.widget.Toast import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandIn -import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -22,15 +25,17 @@ 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.unit.IntSize import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem +import kotlinx.collections.immutable.toImmutableList /** * The vault screen for the application. @@ -40,6 +45,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem fun VaultScreen( viewModel: VaultViewModel = hiltViewModel(), onNavigateToVaultAddItemScreen: () -> Unit, + onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, ) { val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> @@ -94,6 +100,26 @@ fun VaultScreen( .makeText(context, "Navigate to trash screen.", Toast.LENGTH_SHORT) .show() } + + VaultEvent.NavigateToLoginScreen -> { + // TODO: Handle adding accounts (BIT-853) + Toast + .makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT) + .show() + } + + VaultEvent.NavigateToVaultUnlockScreen -> { + // TODO: Handle unlocking accounts (BIT-853) + Toast + .makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT) + .show() + } + + is VaultEvent.ShowToast -> { + Toast + .makeText(context, event.message, Toast.LENGTH_SHORT) + .show() + } } } VaultScreenScaffold( @@ -104,6 +130,13 @@ fun VaultScreen( searchIconClickAction = remember(viewModel) { { viewModel.trySendAction(VaultAction.SearchIconClick) } }, + accountSwitchClickAction = remember(viewModel) { + { viewModel.trySendAction(VaultAction.AccountSwitchClick(it)) } + }, + addAccountClickAction = remember(viewModel) { + { viewModel.trySendAction(VaultAction.AddAccountClick) } + }, + onDimBottomNavBarRequest = onDimBottomNavBarRequest, vaultItemClick = remember(viewModel) { { vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) } }, @@ -138,6 +171,9 @@ private fun VaultScreenScaffold( state: VaultState, addItemClickAction: () -> Unit, searchIconClickAction: () -> Unit, + accountSwitchClickAction: (AccountSummary) -> Unit, + addAccountClickAction: () -> Unit, + onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, folderClick: (VaultState.ViewState.FolderItem) -> Unit, loginGroupClick: () -> Unit, @@ -146,12 +182,18 @@ private fun VaultScreenScaffold( secureNoteGroupClick: () -> Unit, trashClick: () -> Unit, ) { - // TODO Create account menu and logging in ability BIT-205 var accountMenuVisible by rememberSaveable { mutableStateOf(false) } + val updateAccountMenuVisibility = { shouldShowMenu: Boolean -> + accountMenuVisible = shouldShowMenu + onDimBottomNavBarRequest(shouldShowMenu) + } val scrollBehavior = - TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { !accountMenuVisible }, + ) BitwardenScaffold( topBar = { @@ -162,7 +204,9 @@ private fun VaultScreenScaffold( BitwardenAccountActionItem( initials = state.initials, color = state.avatarColor, - onClick = { accountMenuVisible = !accountMenuVisible }, + onClick = { + updateAccountMenuVisibility(!accountMenuVisible) + }, ) BitwardenSearchActionItem( contentDescription = stringResource(id = R.string.search_vault), @@ -175,9 +219,8 @@ private fun VaultScreenScaffold( floatingActionButton = { AnimatedVisibility( visible = !accountMenuVisible, - // The enter transition is required for AnimatedVisibility to work correctly on - // FloatingActionButton. See - https://issuetracker.google.com/issues/224005027?pli=1 - enter = fadeIn() + expandIn { IntSize(width = 1, height = 1) }, + enter = scaleIn(), + exit = scaleOut(), ) { FloatingActionButton( containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -193,23 +236,37 @@ private fun VaultScreenScaffold( }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> - when (val viewState = state.viewState) { - is VaultState.ViewState.Content -> VaultContent( - state = viewState, - vaultItemClick = vaultItemClick, - folderClick = folderClick, - loginGroupClick = loginGroupClick, - cardGroupClick = cardGroupClick, - identityGroupClick = identityGroupClick, - secureNoteGroupClick = secureNoteGroupClick, - trashClick = trashClick, - paddingValues = paddingValues, - ) + Box { + when (val viewState = state.viewState) { + is VaultState.ViewState.Content -> VaultContent( + state = viewState, + vaultItemClick = vaultItemClick, + folderClick = folderClick, + loginGroupClick = loginGroupClick, + cardGroupClick = cardGroupClick, + identityGroupClick = identityGroupClick, + secureNoteGroupClick = secureNoteGroupClick, + trashClick = trashClick, + paddingValues = paddingValues, + ) - is VaultState.ViewState.Loading -> VaultLoading(paddingValues = paddingValues) - is VaultState.ViewState.NoItems -> VaultNoItems( - paddingValues = paddingValues, - addItemClickAction = addItemClickAction, + is VaultState.ViewState.Loading -> VaultLoading(paddingValues = paddingValues) + is VaultState.ViewState.NoItems -> VaultNoItems( + paddingValues = paddingValues, + addItemClickAction = addItemClickAction, + ) + } + + BitwardenAccountSwitcher( + isVisible = accountMenuVisible, + accountSummaries = state.accountSummaries.toImmutableList(), + onAccountSummaryClick = accountSwitchClickAction, + onAddAccountClick = addAccountClickAction, + onDismissRequest = { updateAccountMenuVisibility(false) }, + topAppBarScrollBehavior = scrollBehavior, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index fe41916626..5c79c293bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -6,12 +6,15 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary 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 com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.hexToColor +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -25,14 +28,16 @@ private const val KEY_STATE = "state" /** * Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen]. */ +@Suppress("TooManyFunctions") @HiltViewModel class VaultViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, ) : BaseViewModel( // TODO retrieve this from the data layer BIT-205 initialState = savedStateHandle[KEY_STATE] ?: VaultState( - initials = "BW", - avatarColorString = "FF0000FF", + initials = activeAccountSummary.initials, + avatarColorString = activeAccountSummary.avatarColorHex, + accountSummaries = accountSummaries, viewState = VaultState.ViewState.Loading, ), ) { @@ -79,6 +84,8 @@ class VaultViewModel @Inject constructor( VaultAction.IdentityGroupClick -> handleIdentityClick() VaultAction.LoginGroupClick -> handleLoginClick() VaultAction.SearchIconClick -> handleSearchIconClick() + is VaultAction.AccountSwitchClick -> handleAccountSwitchClick(action) + VaultAction.AddAccountClick -> handleAddAccountClick() VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() VaultAction.TrashClick -> handleTrashClick() is VaultAction.VaultItemClick -> handleVaultItemClick(action) @@ -110,6 +117,28 @@ class VaultViewModel @Inject constructor( sendEvent(VaultEvent.NavigateToVaultSearchScreen) } + private fun handleAccountSwitchClick(action: VaultAction.AccountSwitchClick) { + when (action.accountSummary.status) { + AccountSummary.Status.ACTIVE -> { + // Nothing to do for the active account + } + + AccountSummary.Status.LOCKED -> { + // TODO: Handle switching accounts (BIT-853) + sendEvent(VaultEvent.NavigateToVaultUnlockScreen) + } + + AccountSummary.Status.UNLOCKED -> { + // TODO: Handle switching accounts (BIT-853) + sendEvent(VaultEvent.ShowToast(message = "Not yet implemented.")) + } + } + } + + private fun handleAddAccountClick() { + sendEvent(VaultEvent.NavigateToLoginScreen) + } + private fun handleTrashClick() { sendEvent(VaultEvent.NavigateToTrash) } @@ -124,6 +153,34 @@ class VaultViewModel @Inject constructor( //endregion VaultAction Handlers } +// TODO: Get data from repository (BIT-205) +private val accountSummaries = persistentListOf( + AccountSummary( + userId = "lockedUserId", + name = "Locked User", + email = "locked@bitwarden.com", + avatarColorHex = "#00aaaa", + status = AccountSummary.Status.LOCKED, + ), + AccountSummary( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + status = AccountSummary.Status.ACTIVE, + ), + AccountSummary( + userId = "unlockedUserId", + name = "Unlocked User", + email = "unlocked@bitwarden.com", + avatarColorHex = "#aaaa00", + status = AccountSummary.Status.UNLOCKED, + ), +) + +private val activeAccountSummary = accountSummaries + .first { it.status == AccountSummary.Status.ACTIVE } + /** * Represents the overall state for the [VaultScreen]. * @@ -135,6 +192,7 @@ class VaultViewModel @Inject constructor( data class VaultState( private val avatarColorString: String, val initials: String, + val accountSummaries: List, val viewState: ViewState, ) : Parcelable { @@ -359,6 +417,21 @@ sealed class VaultEvent { * Navigate to the secure notes group screen. */ data object NavigateToSecureNotesGroup : VaultEvent() + + /** + * Navigate to the login flow for an additional account. + */ + data object NavigateToLoginScreen : VaultEvent() + + /** + * Navigate to the vault unlock screen. + */ + data object NavigateToVaultUnlockScreen : VaultEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: String) : VaultEvent() } /** @@ -376,6 +449,18 @@ sealed class VaultAction { */ data object SearchIconClick : VaultAction() + /** + * User clicked an account in the account switcher. + */ + data class AccountSwitchClick( + val accountSummary: AccountSummary, + ) : VaultAction() + + /** + * User clicked on Add Account in the account switcher. + */ + data object AddAccountClick : VaultAction() + /** * Action to trigger when a specific vault item is clicked. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensions.kt new file mode 100644 index 0000000000..aa7835801b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensions.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary + +/** + * Given the [AccountSummary], returns the first two "initials" found when looking at the + * [AccountSummary.name]. + * + * Ex: + * - "First Last" -> "FL" + * - "First Second Last" -> "FS" + */ +val AccountSummary.initials: String + get() = this + .name + .split(" ") + .take(2) + .joinToString(separator = "") { it.first().toString() } + +/** + * Drawable resource to display for the given [AccountSummary]. + */ +@get:DrawableRes +val AccountSummary.iconRes: Int + get() = when (this.status) { + AccountSummary.Status.ACTIVE -> R.drawable.ic_check_mark + AccountSummary.Status.LOCKED -> R.drawable.ic_locked + AccountSummary.Status.UNLOCKED -> R.drawable.ic_unlocked + } + +/** + * String resource of a supporting text to display (or `null`) for the given [AccountSummary]. + */ +@get:StringRes +val AccountSummary.supportingTextResOrNull: Int? + get() = when (this.status) { + AccountSummary.Status.ACTIVE -> null + AccountSummary.Status.LOCKED -> R.string.account_locked + AccountSummary.Status.UNLOCKED -> R.string.account_unlocked + } diff --git a/app/src/main/res/drawable/ic_locked.xml b/app/src/main/res/drawable/ic_locked.xml new file mode 100644 index 0000000000..c602ad6864 --- /dev/null +++ b/app/src/main/res/drawable/ic_locked.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_unlocked.xml b/app/src/main/res/drawable/ic_unlocked.xml new file mode 100644 index 0000000000..4db05faca5 --- /dev/null +++ b/app/src/main/res/drawable/ic_unlocked.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index df43f6fe6f..7ac1865ceb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.vault +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction @@ -9,14 +10,17 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -24,6 +28,7 @@ import org.junit.Test class VaultScreenTest : BaseComposeTest() { private var onNavigateToVaultAddItemScreenCalled = false + private var onDimBottomNavBarRequestCalled = false private val mutableEventFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, @@ -40,10 +45,49 @@ class VaultScreenTest : BaseComposeTest() { VaultScreen( viewModel = viewModel, onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true }, + onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true }, ) } } + @Suppress("MaxLineLength") + @Test + fun `account icon click should show the account switcher and trigger the nav bar dim request`() { + composeTestRule.onNodeWithText("active@bitwarden.com").assertDoesNotExist() + composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist() + composeTestRule.onNodeWithText("Add account").assertDoesNotExist() + assertFalse(onDimBottomNavBarRequestCalled) + + composeTestRule.onNodeWithText("AU").performClick() + + composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed() + composeTestRule.onNodeWithText("locked@bitwarden.com").assertIsDisplayed() + composeTestRule.onNodeWithText("Add account").assertIsDisplayed() + assertTrue(onDimBottomNavBarRequestCalled) + } + + @Suppress("MaxLineLength") + @Test + fun `account click in the account switcher should send AccountSwitchClick and close switcher`() { + // Open the Account Switcher + composeTestRule.onNodeWithText("AU").performClick() + + composeTestRule.onNodeWithText("locked@bitwarden.com").performClick() + verify { viewModel.trySendAction(VaultAction.AccountSwitchClick(LOCKED_ACCOUNT_SUMMARY)) } + composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist() + } + + @Suppress("MaxLineLength") + @Test + fun `Add Account click in the account switcher should send AddAccountClick and close switcher`() { + // Open the Account Switcher + composeTestRule.onNodeWithText("AU").performClick() + + composeTestRule.onNodeWithText("Add account").performClick() + verify { viewModel.trySendAction(VaultAction.AddAccountClick) } + composeTestRule.onNodeWithText("Add account").assertDoesNotExist() + } + @Test fun `search icon click should send SearchIconClick action`() { mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) } @@ -357,9 +401,29 @@ class VaultScreenTest : BaseComposeTest() { } } +private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + status = AccountSummary.Status.ACTIVE, +) + +private val LOCKED_ACCOUNT_SUMMARY = AccountSummary( + userId = "lockedUserId", + name = "Locked User", + email = "locked@bitwarden.com", + avatarColorHex = "#00aaaa", + status = AccountSummary.Status.LOCKED, +) + private val DEFAULT_STATE: VaultState = VaultState( - avatarColorString = "FF0000FF", - initials = "BW", + avatarColorString = "#aa00aa", + initials = "AU", + accountSummaries = persistentListOf( + ACTIVE_ACCOUNT_SUMMARY, + LOCKED_ACCOUNT_SUMMARY, + ), viewState = VaultState.ViewState.Loading, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 3f5c149374..116eacf8a7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.mockk @@ -27,6 +28,58 @@ class VaultViewModelTest : BaseViewModelTest() { assertEquals(state, viewModel.stateFlow.value) } + @Test + fun `on AccountSwitchClick for the active account should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + VaultAction.AccountSwitchClick( + accountSummary = mockk { + every { status } returns AccountSummary.Status.ACTIVE + }, + ) + expectNoEvents() + } + } + + @Test + fun `on AccountSwitchClick for a locked account emit NavigateToVaultUnlockScreen`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAction.AccountSwitchClick( + accountSummary = mockk { + every { status } returns AccountSummary.Status.LOCKED + }, + ), + ) + assertEquals(VaultEvent.NavigateToVaultUnlockScreen, awaitItem()) + } + } + + @Test + fun `on AccountSwitchClick for an unlocked account emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAction.AccountSwitchClick( + accountSummary = mockk { + every { status } returns AccountSummary.Status.UNLOCKED + }, + ), + ) + assertEquals(VaultEvent.ShowToast("Not yet implemented."), awaitItem()) + } + } + + @Test + fun `on AddAccountClick should emit NavigateToLoginScreen`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.AddAccountClick) + assertEquals(VaultEvent.NavigateToLoginScreen, awaitItem()) + } + } + @Test fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest { val viewModel = createViewModel() @@ -126,5 +179,6 @@ class VaultViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE: VaultState = VaultState( avatarColorString = "FF0000FF", initials = "BW", + accountSummaries = emptyList(), viewState = VaultState.ViewState.Loading, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensionsTest.kt new file mode 100644 index 0000000000..6272bb0856 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/AccountSummaryExtensionsTest.kt @@ -0,0 +1,88 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class AccountSummaryExtensionsTest { + + @Test + fun `initials should return the starting letters of the first two words in the name`() { + assertEquals( + "FS", + mockk() { + every { name } returns "First Second Third" + } + .initials, + ) + } + + @Test + fun `iconRes returns a checkmark for active accounts`() { + assertEquals( + R.drawable.ic_check_mark, + mockk() { + every { status } returns AccountSummary.Status.ACTIVE + } + .iconRes, + ) + } + + @Test + fun `iconRes returns a locked lock for locked accounts`() { + assertEquals( + R.drawable.ic_locked, + mockk() { + every { status } returns AccountSummary.Status.LOCKED + } + .iconRes, + ) + } + + @Test + fun `iconRes returns an unlocked lock for unlocked accounts`() { + assertEquals( + R.drawable.ic_unlocked, + mockk() { + every { status } returns AccountSummary.Status.UNLOCKED + } + .iconRes, + ) + } + + @Test + fun `supportingTextResOrNull returns a null for active accounts`() { + assertNull( + mockk() { + every { status } returns AccountSummary.Status.ACTIVE + } + .supportingTextResOrNull, + ) + } + + @Test + fun `supportingTextResOrNull returns Locked locked accounts`() { + assertEquals( + R.string.account_locked, + mockk() { + every { status } returns AccountSummary.Status.LOCKED + } + .supportingTextResOrNull, + ) + } + + @Test + fun `supportingTextResOrNull returns Unlocked for unlocked accounts`() { + assertEquals( + R.string.account_unlocked, + mockk() { + every { status } returns AccountSummary.Status.UNLOCKED + } + .supportingTextResOrNull, + ) + } +}