mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-852: Add account switcher UI (#235)
This commit is contained in:
parent
a43a541719
commit
8e92e2c529
14 changed files with 914 additions and 82 deletions
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
13
app/src/main/res/drawable/ic_locked.xml
Normal file
13
app/src/main/res/drawable/ic_locked.xml
Normal 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>
|
13
app/src/main/res/drawable/ic_unlocked.xml
Normal file
13
app/src/main/res/drawable/ic_unlocked.xml
Normal 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>
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue