BIT-852: Add account switcher UI (#235)

This commit is contained in:
Brian Yencho 2023-11-10 11:54:30 -06:00 committed by Álison Fernandes
parent a43a541719
commit 8e92e2c529
14 changed files with 914 additions and 82 deletions

View file

@ -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,
}
}

View file

@ -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].
*/

View file

@ -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<AccountSummary>,
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<AccountSummary>,
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,
)
}
}

View file

@ -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,
),
)
}
}

View file

@ -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.

View file

@ -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,
)
}
}

View file

@ -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),
)
}
}

View file

@ -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<VaultState, VaultEvent, VaultAction>(
// 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<AccountSummary>,
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.
*/

View file

@ -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
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,11.809C8.153,11.672 8.25,11.472 8.25,11.25C8.25,10.836 7.914,10.5 7.5,10.5C7.086,10.5 6.75,10.836 6.75,11.25C6.75,11.472 6.847,11.672 7,11.809V13.5C7,13.776 7.224,14 7.5,14C7.776,14 8,13.776 8,13.5V11.809Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M3,5L3,4.5C3,2.015 5.015,0 7.5,0C9.985,0 12,2.015 12,4.5V5H12.5C13.328,5 14,5.672 14,6.5V14.5C14,15.328 13.328,16 12.5,16H2.5C1.672,16 1,15.328 1,14.5V6.5C1,5.672 1.672,5 2.5,5H3ZM7.5,1C9.433,1 11,2.567 11,4.5V5H4L4,4.5C4,2.567 5.567,1 7.5,1ZM2.5,6C2.224,6 2,6.224 2,6.5V14.5C2,14.776 2.224,15 2.5,15H12.5C12.776,15 13,14.776 13,14.5V6.5C13,6.224 12.776,6 12.5,6H2.5Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,11.809C8.153,11.672 8.25,11.472 8.25,11.25C8.25,10.836 7.914,10.5 7.5,10.5C7.086,10.5 6.75,10.836 6.75,11.25C6.75,11.472 6.847,11.672 7,11.809V13.5C7,13.776 7.224,14 7.5,14C7.776,14 8,13.776 8,13.5V11.809Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M3.936,6C3.054,4.449 3.617,2.428 5.244,1.489C6.877,0.546 8.918,1.076 9.815,2.63L10.444,3.719C10.582,3.959 10.888,4.041 11.127,3.902C11.366,3.764 11.448,3.459 11.31,3.219L10.681,2.13C9.494,0.074 6.83,-0.582 4.744,0.623C2.829,1.728 2.028,4.038 2.826,6H2.5C1.672,6 1,6.672 1,7.5V14.5C1,15.328 1.672,16 2.5,16H12.5C13.328,16 14,15.328 14,14.5V7.5C14,6.672 13.328,6 12.5,6H3.936ZM2,7.5C2,7.224 2.224,7 2.5,7H12.5C12.776,7 13,7.224 13,7.5V14.5C13,14.776 12.776,15 12.5,15H2.5C2.224,15 2,14.776 2,14.5V7.5Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View file

@ -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<VaultEvent>(
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,
)

View file

@ -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,
)

View file

@ -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<AccountSummary>() {
every { name } returns "First Second Third"
}
.initials,
)
}
@Test
fun `iconRes returns a checkmark for active accounts`() {
assertEquals(
R.drawable.ic_check_mark,
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.ACTIVE
}
.iconRes,
)
}
@Test
fun `iconRes returns a locked lock for locked accounts`() {
assertEquals(
R.drawable.ic_locked,
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.LOCKED
}
.iconRes,
)
}
@Test
fun `iconRes returns an unlocked lock for unlocked accounts`() {
assertEquals(
R.drawable.ic_unlocked,
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.UNLOCKED
}
.iconRes,
)
}
@Test
fun `supportingTextResOrNull returns a null for active accounts`() {
assertNull(
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.ACTIVE
}
.supportingTextResOrNull,
)
}
@Test
fun `supportingTextResOrNull returns Locked locked accounts`() {
assertEquals(
R.string.account_locked,
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.LOCKED
}
.supportingTextResOrNull,
)
}
@Test
fun `supportingTextResOrNull returns Unlocked for unlocked accounts`() {
assertEquals(
R.string.account_unlocked,
mockk<AccountSummary>() {
every { status } returns AccountSummary.Status.UNLOCKED
}
.supportingTextResOrNull,
)
}
}