mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +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
|
package com.x8bit.bitwarden.ui.platform.base.util
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not string is a valid email address.
|
* Whether or not string is a valid email address.
|
||||||
|
@ -25,6 +28,15 @@ fun String.isValidUri(): Boolean =
|
||||||
false
|
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].
|
* 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
|
package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.exclude
|
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.ime
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
@ -17,7 +20,12 @@ import androidx.compose.material3.ScaffoldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
@ -32,6 +40,8 @@ import androidx.navigation.navOptions
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
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.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.components.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE
|
import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE
|
||||||
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph
|
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph
|
||||||
|
@ -113,66 +123,36 @@ private fun VaultUnlockedNavBarScaffold(
|
||||||
navigateToVaultAddItem: () -> Unit,
|
navigateToVaultAddItem: () -> Unit,
|
||||||
navigateToNewSend: () -> Unit,
|
navigateToNewSend: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var shouldDimNavBar by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// This scaffold will host screens that contain top bars while not hosting one itself.
|
// 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
|
// We need to ignore the status bar insets here and let the content screens handle
|
||||||
// it themselves.
|
// it themselves.
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars),
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
BottomAppBar(
|
Box {
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
var appBarHeightPx by remember { mutableIntStateOf(0) }
|
||||||
) {
|
VaultBottomAppBar(
|
||||||
val destinations = listOf(
|
navController = navController,
|
||||||
VaultUnlockedNavBarTab.Vault,
|
vaultTabClickedAction = vaultTabClickedAction,
|
||||||
VaultUnlockedNavBarTab.Send,
|
sendTabClickedAction = sendTabClickedAction,
|
||||||
VaultUnlockedNavBarTab.Generator,
|
generatorTabClickedAction = generatorTabClickedAction,
|
||||||
VaultUnlockedNavBarTab.Settings,
|
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 ->
|
) { innerPadding ->
|
||||||
|
@ -195,6 +175,9 @@ private fun VaultUnlockedNavBarScaffold(
|
||||||
onNavigateToVaultAddItemScreen = {
|
onNavigateToVaultAddItemScreen = {
|
||||||
navigateToVaultAddItem()
|
navigateToVaultAddItem()
|
||||||
},
|
},
|
||||||
|
onDimBottomNavBarRequest = { shouldDim ->
|
||||||
|
shouldDimNavBar = shouldDim
|
||||||
|
},
|
||||||
)
|
)
|
||||||
sendGraph(onNavigateToNewSend = navigateToNewSend)
|
sendGraph(onNavigateToNewSend = navigateToNewSend)
|
||||||
generatorDestination()
|
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
|
* Represents the different tabs available in the navigation bar
|
||||||
* for the unlocked portion of the vault.
|
* for the unlocked portion of the vault.
|
||||||
|
|
|
@ -12,10 +12,12 @@ const val VAULT_ROUTE: String = "vault"
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.vaultDestination(
|
fun NavGraphBuilder.vaultDestination(
|
||||||
onNavigateToVaultAddItemScreen: () -> Unit,
|
onNavigateToVaultAddItemScreen: () -> Unit,
|
||||||
|
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
composable(VAULT_ROUTE) {
|
composable(VAULT_ROUTE) {
|
||||||
VaultScreen(
|
VaultScreen(
|
||||||
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
|
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
|
||||||
|
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,11 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.fadeIn
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.x8bit.bitwarden.R
|
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.base.util.EventsEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
|
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.BitwardenMediumTopAppBar
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The vault screen for the application.
|
* The vault screen for the application.
|
||||||
|
@ -40,6 +45,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
|
||||||
fun VaultScreen(
|
fun VaultScreen(
|
||||||
viewModel: VaultViewModel = hiltViewModel(),
|
viewModel: VaultViewModel = hiltViewModel(),
|
||||||
onNavigateToVaultAddItemScreen: () -> Unit,
|
onNavigateToVaultAddItemScreen: () -> Unit,
|
||||||
|
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
|
@ -94,6 +100,26 @@ fun VaultScreen(
|
||||||
.makeText(context, "Navigate to trash screen.", Toast.LENGTH_SHORT)
|
.makeText(context, "Navigate to trash screen.", Toast.LENGTH_SHORT)
|
||||||
.show()
|
.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(
|
VaultScreenScaffold(
|
||||||
|
@ -104,6 +130,13 @@ fun VaultScreen(
|
||||||
searchIconClickAction = remember(viewModel) {
|
searchIconClickAction = remember(viewModel) {
|
||||||
{ viewModel.trySendAction(VaultAction.SearchIconClick) }
|
{ viewModel.trySendAction(VaultAction.SearchIconClick) }
|
||||||
},
|
},
|
||||||
|
accountSwitchClickAction = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultAction.AccountSwitchClick(it)) }
|
||||||
|
},
|
||||||
|
addAccountClickAction = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultAction.AddAccountClick) }
|
||||||
|
},
|
||||||
|
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
|
||||||
vaultItemClick = remember(viewModel) {
|
vaultItemClick = remember(viewModel) {
|
||||||
{ vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) }
|
{ vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) }
|
||||||
},
|
},
|
||||||
|
@ -138,6 +171,9 @@ private fun VaultScreenScaffold(
|
||||||
state: VaultState,
|
state: VaultState,
|
||||||
addItemClickAction: () -> Unit,
|
addItemClickAction: () -> Unit,
|
||||||
searchIconClickAction: () -> Unit,
|
searchIconClickAction: () -> Unit,
|
||||||
|
accountSwitchClickAction: (AccountSummary) -> Unit,
|
||||||
|
addAccountClickAction: () -> Unit,
|
||||||
|
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
|
||||||
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
|
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
|
||||||
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
|
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
|
||||||
loginGroupClick: () -> Unit,
|
loginGroupClick: () -> Unit,
|
||||||
|
@ -146,12 +182,18 @@ private fun VaultScreenScaffold(
|
||||||
secureNoteGroupClick: () -> Unit,
|
secureNoteGroupClick: () -> Unit,
|
||||||
trashClick: () -> Unit,
|
trashClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// TODO Create account menu and logging in ability BIT-205
|
|
||||||
var accountMenuVisible by rememberSaveable {
|
var accountMenuVisible by rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
val updateAccountMenuVisibility = { shouldShowMenu: Boolean ->
|
||||||
|
accountMenuVisible = shouldShowMenu
|
||||||
|
onDimBottomNavBarRequest(shouldShowMenu)
|
||||||
|
}
|
||||||
val scrollBehavior =
|
val scrollBehavior =
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||||
|
state = rememberTopAppBarState(),
|
||||||
|
canScroll = { !accountMenuVisible },
|
||||||
|
)
|
||||||
|
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
@ -162,7 +204,9 @@ private fun VaultScreenScaffold(
|
||||||
BitwardenAccountActionItem(
|
BitwardenAccountActionItem(
|
||||||
initials = state.initials,
|
initials = state.initials,
|
||||||
color = state.avatarColor,
|
color = state.avatarColor,
|
||||||
onClick = { accountMenuVisible = !accountMenuVisible },
|
onClick = {
|
||||||
|
updateAccountMenuVisibility(!accountMenuVisible)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
BitwardenSearchActionItem(
|
BitwardenSearchActionItem(
|
||||||
contentDescription = stringResource(id = R.string.search_vault),
|
contentDescription = stringResource(id = R.string.search_vault),
|
||||||
|
@ -175,9 +219,8 @@ private fun VaultScreenScaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !accountMenuVisible,
|
visible = !accountMenuVisible,
|
||||||
// The enter transition is required for AnimatedVisibility to work correctly on
|
enter = scaleIn(),
|
||||||
// FloatingActionButton. See - https://issuetracker.google.com/issues/224005027?pli=1
|
exit = scaleOut(),
|
||||||
enter = fadeIn() + expandIn { IntSize(width = 1, height = 1) },
|
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
@ -193,23 +236,37 @@ private fun VaultScreenScaffold(
|
||||||
},
|
},
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when (val viewState = state.viewState) {
|
Box {
|
||||||
is VaultState.ViewState.Content -> VaultContent(
|
when (val viewState = state.viewState) {
|
||||||
state = viewState,
|
is VaultState.ViewState.Content -> VaultContent(
|
||||||
vaultItemClick = vaultItemClick,
|
state = viewState,
|
||||||
folderClick = folderClick,
|
vaultItemClick = vaultItemClick,
|
||||||
loginGroupClick = loginGroupClick,
|
folderClick = folderClick,
|
||||||
cardGroupClick = cardGroupClick,
|
loginGroupClick = loginGroupClick,
|
||||||
identityGroupClick = identityGroupClick,
|
cardGroupClick = cardGroupClick,
|
||||||
secureNoteGroupClick = secureNoteGroupClick,
|
identityGroupClick = identityGroupClick,
|
||||||
trashClick = trashClick,
|
secureNoteGroupClick = secureNoteGroupClick,
|
||||||
paddingValues = paddingValues,
|
trashClick = trashClick,
|
||||||
)
|
paddingValues = paddingValues,
|
||||||
|
)
|
||||||
|
|
||||||
is VaultState.ViewState.Loading -> VaultLoading(paddingValues = paddingValues)
|
is VaultState.ViewState.Loading -> VaultLoading(paddingValues = paddingValues)
|
||||||
is VaultState.ViewState.NoItems -> VaultNoItems(
|
is VaultState.ViewState.NoItems -> VaultNoItems(
|
||||||
paddingValues = paddingValues,
|
paddingValues = paddingValues,
|
||||||
addItemClickAction = addItemClickAction,
|
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.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.x8bit.bitwarden.R
|
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.BaseViewModel
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
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.asText
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
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].
|
* Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen].
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class VaultViewModel @Inject constructor(
|
class VaultViewModel @Inject constructor(
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
|
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
|
||||||
// TODO retrieve this from the data layer BIT-205
|
// TODO retrieve this from the data layer BIT-205
|
||||||
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
|
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
|
||||||
initials = "BW",
|
initials = activeAccountSummary.initials,
|
||||||
avatarColorString = "FF0000FF",
|
avatarColorString = activeAccountSummary.avatarColorHex,
|
||||||
|
accountSummaries = accountSummaries,
|
||||||
viewState = VaultState.ViewState.Loading,
|
viewState = VaultState.ViewState.Loading,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
@ -79,6 +84,8 @@ class VaultViewModel @Inject constructor(
|
||||||
VaultAction.IdentityGroupClick -> handleIdentityClick()
|
VaultAction.IdentityGroupClick -> handleIdentityClick()
|
||||||
VaultAction.LoginGroupClick -> handleLoginClick()
|
VaultAction.LoginGroupClick -> handleLoginClick()
|
||||||
VaultAction.SearchIconClick -> handleSearchIconClick()
|
VaultAction.SearchIconClick -> handleSearchIconClick()
|
||||||
|
is VaultAction.AccountSwitchClick -> handleAccountSwitchClick(action)
|
||||||
|
VaultAction.AddAccountClick -> handleAddAccountClick()
|
||||||
VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
|
VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
|
||||||
VaultAction.TrashClick -> handleTrashClick()
|
VaultAction.TrashClick -> handleTrashClick()
|
||||||
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
|
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
|
||||||
|
@ -110,6 +117,28 @@ class VaultViewModel @Inject constructor(
|
||||||
sendEvent(VaultEvent.NavigateToVaultSearchScreen)
|
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() {
|
private fun handleTrashClick() {
|
||||||
sendEvent(VaultEvent.NavigateToTrash)
|
sendEvent(VaultEvent.NavigateToTrash)
|
||||||
}
|
}
|
||||||
|
@ -124,6 +153,34 @@ class VaultViewModel @Inject constructor(
|
||||||
//endregion VaultAction Handlers
|
//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].
|
* Represents the overall state for the [VaultScreen].
|
||||||
*
|
*
|
||||||
|
@ -135,6 +192,7 @@ class VaultViewModel @Inject constructor(
|
||||||
data class VaultState(
|
data class VaultState(
|
||||||
private val avatarColorString: String,
|
private val avatarColorString: String,
|
||||||
val initials: String,
|
val initials: String,
|
||||||
|
val accountSummaries: List<AccountSummary>,
|
||||||
val viewState: ViewState,
|
val viewState: ViewState,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
|
@ -359,6 +417,21 @@ sealed class VaultEvent {
|
||||||
* Navigate to the secure notes group screen.
|
* Navigate to the secure notes group screen.
|
||||||
*/
|
*/
|
||||||
data object NavigateToSecureNotesGroup : VaultEvent()
|
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()
|
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.
|
* 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
|
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.assertTextEquals
|
||||||
import androidx.compose.ui.test.filterToOne
|
import androidx.compose.ui.test.filterToOne
|
||||||
import androidx.compose.ui.test.hasClickAction
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollToNode
|
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.BaseComposeTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -24,6 +28,7 @@ import org.junit.Test
|
||||||
class VaultScreenTest : BaseComposeTest() {
|
class VaultScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
private var onNavigateToVaultAddItemScreenCalled = false
|
private var onNavigateToVaultAddItemScreenCalled = false
|
||||||
|
private var onDimBottomNavBarRequestCalled = false
|
||||||
|
|
||||||
private val mutableEventFlow = MutableSharedFlow<VaultEvent>(
|
private val mutableEventFlow = MutableSharedFlow<VaultEvent>(
|
||||||
extraBufferCapacity = Int.MAX_VALUE,
|
extraBufferCapacity = Int.MAX_VALUE,
|
||||||
|
@ -40,10 +45,49 @@ class VaultScreenTest : BaseComposeTest() {
|
||||||
VaultScreen(
|
VaultScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
|
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
|
@Test
|
||||||
fun `search icon click should send SearchIconClick action`() {
|
fun `search icon click should send SearchIconClick action`() {
|
||||||
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
|
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(
|
private val DEFAULT_STATE: VaultState = VaultState(
|
||||||
avatarColorString = "FF0000FF",
|
avatarColorString = "#aa00aa",
|
||||||
initials = "BW",
|
initials = "AU",
|
||||||
|
accountSummaries = persistentListOf(
|
||||||
|
ACTIVE_ACCOUNT_SUMMARY,
|
||||||
|
LOCKED_ACCOUNT_SUMMARY,
|
||||||
|
),
|
||||||
viewState = VaultState.ViewState.Loading,
|
viewState = VaultState.ViewState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
@ -27,6 +28,58 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||||
assertEquals(state, viewModel.stateFlow.value)
|
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
|
@Test
|
||||||
fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest {
|
fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
@ -126,5 +179,6 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||||
private val DEFAULT_STATE: VaultState = VaultState(
|
private val DEFAULT_STATE: VaultState = VaultState(
|
||||||
avatarColorString = "FF0000FF",
|
avatarColorString = "FF0000FF",
|
||||||
initials = "BW",
|
initials = "BW",
|
||||||
|
accountSummaries = emptyList(),
|
||||||
viewState = VaultState.ViewState.Loading,
|
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