mirror of
https://github.com/bitwarden/android.git
synced 2026-08-26 22:04:07 -05:00
[PM-19779] Make Authenticator TOTP codes collapsible (#5452)
This commit is contained in:
+206
-99
@@ -36,8 +36,11 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
@@ -72,7 +75,7 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoBut
|
||||
import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState
|
||||
import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon
|
||||
import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton
|
||||
import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.bitwarden.authenticator.ui.platform.components.header.AuthenticatorExpandingHeader
|
||||
import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderTextWithSupportLabel
|
||||
import com.bitwarden.authenticator.ui.platform.components.model.IconResource
|
||||
import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
|
||||
@@ -85,6 +88,7 @@ import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.util.asText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -245,6 +249,9 @@ fun ItemListingScreen(
|
||||
onSyncLearnMoreClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) }
|
||||
},
|
||||
onSectionExpandedClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ItemListingAction.SectionExpandedClick(it)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +365,7 @@ private fun ItemListingContent(
|
||||
onSyncWithBitwardenClick: () -> Unit,
|
||||
onDismissSyncWithBitwardenClick: () -> Unit,
|
||||
onSyncLearnMoreClick: () -> Unit,
|
||||
onSectionExpandedClick: (SharedCodesDisplayState.SharedCodesAccountSection) -> Unit,
|
||||
) {
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
@@ -414,80 +422,45 @@ private fun ItemListingContent(
|
||||
floatingActionButtonPosition = FabPosition.EndOverlay,
|
||||
snackbarHost = { FirstTimeSyncSnackbarHost(state = snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
var isLocalHeaderExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
LazyColumn {
|
||||
item {
|
||||
ActionCard(
|
||||
actionCardState = state.actionCard,
|
||||
onDownloadBitwardenClick = onDownloadBitwardenClick,
|
||||
onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick,
|
||||
onSyncWithBitwardenClick = onSyncWithBitwardenClick,
|
||||
onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick,
|
||||
onSyncLearnMoreClick = onSyncLearnMoreClick,
|
||||
modifier = Modifier.padding(all = 16.dp),
|
||||
item(key = "action_card") {
|
||||
ActionCard(
|
||||
actionCardState = state.actionCard,
|
||||
onDownloadBitwardenClick = onDownloadBitwardenClick,
|
||||
onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick,
|
||||
onSyncWithBitwardenClick = onSyncWithBitwardenClick,
|
||||
onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick,
|
||||
onSyncLearnMoreClick = onSyncLearnMoreClick,
|
||||
modifier = Modifier
|
||||
.padding(all = 16.dp)
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
if (state.favoriteItems.isNotEmpty()) {
|
||||
item(key = "favorites_header") {
|
||||
BitwardenListHeaderTextWithSupportLabel(
|
||||
label = stringResource(id = R.string.favorites),
|
||||
supportingLabel = state.favoriteItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
if (state.favoriteItems.isNotEmpty()) {
|
||||
item {
|
||||
BitwardenListHeaderTextWithSupportLabel(
|
||||
label = stringResource(id = R.string.favorites),
|
||||
supportingLabel = state.favoriteItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
items(state.favoriteItems) {
|
||||
VaultVerificationCodeItem(
|
||||
authCode = it.authCode,
|
||||
primaryLabel = it.title,
|
||||
secondaryLabel = it.subtitle,
|
||||
periodSeconds = it.periodSeconds,
|
||||
timeLeftSeconds = it.timeLeftSeconds,
|
||||
alertThresholdSeconds = it.alertThresholdSeconds,
|
||||
startIcon = it.startIcon,
|
||||
onItemClick = { onItemClick(it.authCode) },
|
||||
onDropdownMenuClick = { action ->
|
||||
onDropdownMenuClick(action, it)
|
||||
},
|
||||
showMoveToBitwarden = it.showMoveToBitwarden,
|
||||
allowLongPress = it.allowLongPressActions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (state.shouldShowLocalHeader) {
|
||||
item {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.local_codes),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(state.itemList) {
|
||||
items(
|
||||
items = state.favoriteItems,
|
||||
key = { "favorite_item_${it.id}" },
|
||||
) {
|
||||
VaultVerificationCodeItem(
|
||||
authCode = it.authCode,
|
||||
primaryLabel = it.title,
|
||||
@@ -506,26 +479,87 @@ private fun ItemListingContent(
|
||||
)
|
||||
}
|
||||
|
||||
// If there are any items in the local lists, add a spacer between
|
||||
// local codes and shared codes:
|
||||
if (state.itemList.isNotEmpty() || state.favoriteItems.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
item(key = "favorites_divider") {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp)
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.sharedItems) {
|
||||
is SharedCodesDisplayState.Codes -> {
|
||||
state.sharedItems.sections.forEach { section ->
|
||||
item {
|
||||
BitwardenListHeaderText(
|
||||
label = section.label(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
items(section.codes) {
|
||||
if (state.shouldShowLocalHeader) {
|
||||
item(key = "local_items_header") {
|
||||
AuthenticatorExpandingHeader(
|
||||
label = stringResource(id = R.string.local_codes, state.itemList.size),
|
||||
isExpanded = isLocalHeaderExpanded,
|
||||
onClick = { isLocalHeaderExpanded = !isLocalHeaderExpanded },
|
||||
onClickLabel = if (isLocalHeaderExpanded) {
|
||||
stringResource(R.string.local_items_are_expanded_click_to_collapse)
|
||||
} else {
|
||||
stringResource(R.string.local_items_are_collapsed_click_to_expand)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalHeaderExpanded) {
|
||||
items(
|
||||
items = state.itemList,
|
||||
key = { "local_item_${it.id}" },
|
||||
) {
|
||||
VaultVerificationCodeItem(
|
||||
authCode = it.authCode,
|
||||
primaryLabel = it.title,
|
||||
secondaryLabel = it.subtitle,
|
||||
periodSeconds = it.periodSeconds,
|
||||
timeLeftSeconds = it.timeLeftSeconds,
|
||||
alertThresholdSeconds = it.alertThresholdSeconds,
|
||||
startIcon = it.startIcon,
|
||||
onItemClick = { onItemClick(it.authCode) },
|
||||
onDropdownMenuClick = { action ->
|
||||
onDropdownMenuClick(action, it)
|
||||
},
|
||||
showMoveToBitwarden = it.showMoveToBitwarden,
|
||||
allowLongPress = it.allowLongPressActions,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.sharedItems) {
|
||||
is SharedCodesDisplayState.Codes -> {
|
||||
state.sharedItems.sections.forEachIndexed { index, section ->
|
||||
item(key = "sharedSection_${section.label}") {
|
||||
AuthenticatorExpandingHeader(
|
||||
label = section.label(),
|
||||
isExpanded = section.isExpanded,
|
||||
onClick = {
|
||||
onSectionExpandedClick(section)
|
||||
},
|
||||
onClickLabel = if (section.isExpanded) {
|
||||
stringResource(R.string.items_expanded_click_to_collapse)
|
||||
} else {
|
||||
stringResource(R.string.items_are_collapsed_click_to_expand)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
if (section.isExpanded) {
|
||||
items(
|
||||
items = section.codes,
|
||||
key = { code -> "code_${code.id}" },
|
||||
) {
|
||||
VaultVerificationCodeItem(
|
||||
authCode = it.authCode,
|
||||
primaryLabel = it.title,
|
||||
@@ -540,29 +574,33 @@ private fun ItemListingContent(
|
||||
},
|
||||
showMoveToBitwarden = it.showMoveToBitwarden,
|
||||
allowLongPress = it.allowLongPressActions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedCodesDisplayState.Error -> {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.shared_codes_error),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
SharedCodesDisplayState.Error -> {
|
||||
item(key = "shared_codes_error") {
|
||||
Text(
|
||||
text = stringResource(R.string.shared_codes_error),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a spacer item to prevent the FAB from hiding verification codes at the
|
||||
// bottom of the list
|
||||
item {
|
||||
Spacer(Modifier.height(72.dp))
|
||||
}
|
||||
// Add a spacer item to prevent the FAB from hiding verification codes at the
|
||||
// bottom of the list
|
||||
item {
|
||||
Spacer(Modifier.height(72.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -869,3 +907,72 @@ private fun EmptyListingContentPreview() {
|
||||
onDismissSyncWithBitwardenClick = { },
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
private fun ContentPreview() {
|
||||
BitwardenTheme {
|
||||
ItemListingContent(
|
||||
state = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = emptyList(),
|
||||
itemList = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
id = "",
|
||||
title = "Local item",
|
||||
subtitle = "with a subtitle",
|
||||
timeLeftSeconds = 20,
|
||||
periodSeconds = 30,
|
||||
alertThresholdSeconds = 15,
|
||||
authCode = "123456",
|
||||
favorite = false,
|
||||
showMoveToBitwarden = true,
|
||||
allowLongPressActions = true,
|
||||
),
|
||||
),
|
||||
sharedItems = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "id",
|
||||
label =
|
||||
"longemailaddress+verification+codes@email.com | Bitawrden.eu (1)"
|
||||
.asText(),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
id = "",
|
||||
title = "Shared item",
|
||||
subtitle = "with a subtitle",
|
||||
timeLeftSeconds = 15,
|
||||
periodSeconds = 30,
|
||||
alertThresholdSeconds = 15,
|
||||
authCode = "123456",
|
||||
favorite = false,
|
||||
showMoveToBitwarden = false,
|
||||
allowLongPressActions = false,
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
snackbarHostState = remember { SnackbarHostState() },
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
),
|
||||
onNavigateToSearch = { },
|
||||
onScanQrCodeClick = { },
|
||||
onEnterSetupKeyClick = { },
|
||||
onItemClick = { },
|
||||
onDropdownMenuClick = { _, _ -> },
|
||||
onDownloadBitwardenClick = { },
|
||||
onDismissDownloadBitwardenClick = { },
|
||||
onSyncWithBitwardenClick = { },
|
||||
onDismissSyncWithBitwardenClick = { },
|
||||
onSyncLearnMoreClick = { },
|
||||
onSectionExpandedClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-3
@@ -157,6 +157,10 @@ class ItemListingViewModel @Inject constructor(
|
||||
ItemListingAction.SyncLearnMoreClick -> {
|
||||
handleSyncLearnMoreClick()
|
||||
}
|
||||
|
||||
is ItemListingAction.SectionExpandedClick -> {
|
||||
handleSectionExpandedClick(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +456,7 @@ class ItemListingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleAuthenticatorDataReceive(
|
||||
action: ItemListingAction.Internal.AuthCodesUpdated,
|
||||
) {
|
||||
@@ -473,10 +478,14 @@ class ItemListingViewModel @Inject constructor(
|
||||
SharedVerificationCodesState.SyncNotEnabled,
|
||||
-> SharedCodesDisplayState.Codes(emptyList())
|
||||
|
||||
is SharedVerificationCodesState.Success ->
|
||||
is SharedVerificationCodesState.Success -> {
|
||||
val viewState = state.viewState as? ItemListingState.ViewState.Content
|
||||
val currentCodes = viewState?.sharedItems as? SharedCodesDisplayState.Codes
|
||||
action.sharedCodesState.toSharedCodesDisplayState(
|
||||
alertThresholdSeconds = state.alertThresholdSeconds,
|
||||
currentSections = currentCodes?.sections.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (localItems.isEmpty() && sharedItemsState.isEmpty()) {
|
||||
@@ -496,7 +505,7 @@ class ItemListingViewModel @Inject constructor(
|
||||
it.toDisplayItem(
|
||||
alertThresholdSeconds = state.alertThresholdSeconds,
|
||||
sharedVerificationCodesState =
|
||||
authenticatorRepository.sharedCodesStateFlow.value,
|
||||
authenticatorRepository.sharedCodesStateFlow.value,
|
||||
)
|
||||
},
|
||||
itemList = localItems
|
||||
@@ -505,7 +514,7 @@ class ItemListingViewModel @Inject constructor(
|
||||
it.toDisplayItem(
|
||||
alertThresholdSeconds = state.alertThresholdSeconds,
|
||||
sharedVerificationCodesState =
|
||||
authenticatorRepository.sharedCodesStateFlow.value,
|
||||
authenticatorRepository.sharedCodesStateFlow.value,
|
||||
)
|
||||
},
|
||||
sharedItems = sharedItemsState,
|
||||
@@ -572,6 +581,22 @@ class ItemListingViewModel @Inject constructor(
|
||||
sendEvent(ItemListingEvent.NavigateToSyncInformation)
|
||||
}
|
||||
|
||||
private fun handleSectionExpandedClick(action: ItemListingAction.SectionExpandedClick) {
|
||||
updateSharedItems { codes ->
|
||||
codes.copy(
|
||||
sections = codes.sections.map {
|
||||
it.copy(
|
||||
isExpanded = if (it == action.section) {
|
||||
!it.isExpanded
|
||||
} else {
|
||||
it.isExpanded
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [SharedVerificationCodesState] into an action card for display.
|
||||
*/
|
||||
@@ -645,6 +670,29 @@ class ItemListingViewModel @Inject constructor(
|
||||
favorite = false,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun updateContent(
|
||||
crossinline block: (
|
||||
ItemListingState.ViewState.Content,
|
||||
) -> ItemListingState.ViewState.Content,
|
||||
) {
|
||||
val updatedContent = (state.viewState as? ItemListingState.ViewState.Content)
|
||||
?.let(block)
|
||||
?: return
|
||||
mutableStateFlow.update { it.copy(viewState = updatedContent) }
|
||||
}
|
||||
|
||||
private inline fun updateSharedItems(
|
||||
crossinline block: (SharedCodesDisplayState.Codes) -> SharedCodesDisplayState.Codes,
|
||||
) {
|
||||
updateContent {
|
||||
it.copy(
|
||||
sharedItems = (it.sharedItems as? SharedCodesDisplayState.Codes)
|
||||
?.let(block)
|
||||
?: it.sharedItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val ALGORITHM = "algorithm"
|
||||
@@ -890,6 +938,13 @@ sealed class ItemListingAction {
|
||||
*/
|
||||
data object SyncLearnMoreClick : ItemListingAction()
|
||||
|
||||
/**
|
||||
* The user tapped the section header to expand or collapse the section.
|
||||
*/
|
||||
data class SectionExpandedClick(
|
||||
val section: SharedCodesDisplayState.SharedCodesAccountSection,
|
||||
) : ItemListingAction()
|
||||
|
||||
/**
|
||||
* The user dismissed sync Bitwarden action card.
|
||||
*/
|
||||
|
||||
+2
@@ -26,8 +26,10 @@ sealed class SharedCodesDisplayState : Parcelable {
|
||||
*/
|
||||
@Parcelize
|
||||
data class SharedCodesAccountSection(
|
||||
val id: String,
|
||||
val label: Text,
|
||||
val codes: List<VerificationCodeDisplayItem>,
|
||||
val isExpanded: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
|
||||
+7
@@ -12,6 +12,7 @@ import com.bitwarden.ui.util.asText
|
||||
*/
|
||||
fun SharedVerificationCodesState.Success.toSharedCodesDisplayState(
|
||||
alertThresholdSeconds: Int,
|
||||
currentSections: List<SharedCodesDisplayState.SharedCodesAccountSection> = emptyList(),
|
||||
): SharedCodesDisplayState.Codes {
|
||||
val codesMap =
|
||||
mutableMapOf<AuthenticatorItem.Source.Shared, MutableList<VerificationCodeDisplayItem>>()
|
||||
@@ -32,11 +33,17 @@ fun SharedVerificationCodesState.Success.toSharedCodesDisplayState(
|
||||
return codesMap
|
||||
.map {
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = it.key.userId,
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
it.key.email,
|
||||
it.key.environmentLabel,
|
||||
it.value.size,
|
||||
),
|
||||
codes = it.value,
|
||||
isExpanded = currentSections
|
||||
?.find { section -> section.id == it.key.userId }
|
||||
?.isExpanded
|
||||
?: true,
|
||||
)
|
||||
}
|
||||
.let { SharedCodesDisplayState.Codes(it) }
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.bitwarden.authenticator.ui.platform.components.header
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
|
||||
/**
|
||||
* A header that can be expanded and collapsed.
|
||||
*/
|
||||
@Composable
|
||||
fun AuthenticatorExpandingHeader(
|
||||
label: String,
|
||||
isExpanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onClickLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
insets: PaddingValues = PaddingValues(top = 16.dp, bottom = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClickLabel = onClickLabel,
|
||||
onClick = onClick,
|
||||
)
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(paddingValues = insets)
|
||||
.padding(horizontal = 16.dp)
|
||||
.semantics(mergeDescendants = true) {},
|
||||
) {
|
||||
val iconRotationDegrees = animateFloatAsState(
|
||||
targetValue = if (isExpanded) 0f else 180f,
|
||||
label = "expanderIconRotationAnimation",
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(width = 8.dp))
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_up_small),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.rotate(degrees = iconRotationDegrees.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
private fun ExpandingHeaderPreview() {
|
||||
Column {
|
||||
AuthenticatorExpandingHeader(
|
||||
label = "Label Collapsed",
|
||||
isExpanded = false,
|
||||
onClick = { },
|
||||
onClickLabel = "",
|
||||
)
|
||||
AuthenticatorExpandingHeader(
|
||||
label = "Label Expanded",
|
||||
isExpanded = true,
|
||||
onClick = { },
|
||||
onClickLabel = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@
|
||||
<string name="sync_with_bitwarden_app">Sync with Bitwarden app</string>
|
||||
<string name="this_feature_is_not_not_yet_available_for_self_hosted_users">This feature is not yet available for self-hosted users. <annotation link="learnMore">Learn more</annotation></string>
|
||||
<string name="shared_codes_error">Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.</string>
|
||||
<string name="shared_accounts_header">%1$s | %2$s</string>
|
||||
<string name="shared_accounts_header">%1$s | %2$s (%3$d)</string>
|
||||
<string name="sync_with_the_bitwarden_app">Sync with the Bitwarden app</string>
|
||||
<string name="sync_with_bitwarden_action_card_message">In order to view all of your verification codes, you’ll need to allow for syncing on all of your accounts.</string>
|
||||
<string name="take_me_to_app_settings">Take me to the app settings</string>
|
||||
@@ -135,11 +135,15 @@
|
||||
<string name="choose_save_location_message">Save this authenticator key here, or add it to a login in your Bitwarden app.</string>
|
||||
<string name="save_option_as_default">Save option as default</string>
|
||||
<string name="account_synced_from_bitwarden_app">Account synced from Bitwarden app</string>
|
||||
<string name="local_codes">Local codes</string>
|
||||
<string name="local_codes">Local codes (%1$d)</string>
|
||||
<string name="required_information_missing">Required Information Missing</string>
|
||||
<string name="required_information_missing_message">"Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support"</string>
|
||||
<string name="file_could_not_be_processed">"File Could Not Be Processed"</string>
|
||||
<string name="file_could_not_be_processed_message">"File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help"</string>
|
||||
<string name="get_help">Get Help</string>
|
||||
<string name="expand_advanced_options">Expand advanced options</string>
|
||||
<string name="local_items_are_expanded_click_to_collapse">Local items are expanded, click to collapse.</string>
|
||||
<string name="local_items_are_collapsed_click_to_expand">Local items are collapsed, click to expand.</string>
|
||||
<string name="items_expanded_click_to_collapse">Items are expanded, click to collapse.</string>
|
||||
<string name="items_are_collapsed_click_to_expand">Items are collapsed, click to expand.</string>
|
||||
</resources>
|
||||
|
||||
+115
-1
@@ -418,6 +418,118 @@ class ItemListingScreenTest : AuthenticatorComposeTest() {
|
||||
.onNodeWithText("Account synced from Bitwarden app")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local codes header should be displayed and expanded when syncing is enabled`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = emptyList(),
|
||||
itemList = listOf(LOCAL_CODE),
|
||||
sharedItems = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SHARED_ACCOUNTS_SECTION,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Local codes (1)")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(LOCAL_CODE.title)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared codes header click should emit SectionExpandedClick`() {
|
||||
val sharedItems = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(SHARED_ACCOUNTS_SECTION),
|
||||
)
|
||||
val viewState = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = emptyList(),
|
||||
itemList = listOf(LOCAL_CODE),
|
||||
sharedItems = sharedItems,
|
||||
)
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(viewState = viewState)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(ItemListingAction.SectionExpandedClick(SHARED_ACCOUNTS_SECTION))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared codes header should be displayed and collapsed when syncing is enabled`() {
|
||||
val sharedItems = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(SHARED_ACCOUNTS_SECTION),
|
||||
)
|
||||
val viewState = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = emptyList(),
|
||||
itemList = listOf(LOCAL_CODE),
|
||||
sharedItems = sharedItems,
|
||||
)
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(viewState = viewState)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(SHARED_ACCOUNTS_SECTION.codes[0].title)
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = viewState.copy(
|
||||
sharedItems = sharedItems.copy(
|
||||
sections = listOf(SHARED_ACCOUNTS_SECTION.copy(isExpanded = false)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(SHARED_ACCOUNTS_SECTION.codes[0].title)
|
||||
.assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local codes should be displayed based on expanding header state`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = emptyList(),
|
||||
itemList = listOf(LOCAL_CODE),
|
||||
sharedItems = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SHARED_ACCOUNTS_SECTION,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(LOCAL_CODE.title)
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Local codes (1)")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(LOCAL_CODE.title)
|
||||
.assertIsNotDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
private val APP_THEME = AppTheme.DEFAULT
|
||||
@@ -437,7 +549,8 @@ private val LOCAL_CODE = VerificationCodeDisplayItem(
|
||||
)
|
||||
|
||||
private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
label = "test@test.com".asText(),
|
||||
id = "id",
|
||||
label = "test@test.com | bitwarden.com (1)".asText(),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
id = "1",
|
||||
@@ -452,6 +565,7 @@ private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccount
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE = ItemListingState(
|
||||
|
||||
+25
@@ -504,6 +504,31 @@ class ItemListingViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SectionExpandedClick should update expanded state for clicked section`() = runTest {
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
viewState = ItemListingState.ViewState.Content(
|
||||
actionCard = ItemListingState.ActionCardState.None,
|
||||
favoriteItems = LOCAL_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) },
|
||||
itemList = LOCAL_NON_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) },
|
||||
sharedItems = SHARED_DISPLAY_ITEMS.copy(
|
||||
sections = SHARED_DISPLAY_ITEMS.sections.map { it.copy(isExpanded = false) },
|
||||
),
|
||||
),
|
||||
)
|
||||
mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS)
|
||||
mutableSharedCodesFlow.value =
|
||||
SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS)
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(
|
||||
ItemListingAction.SectionExpandedClick(section = SHARED_DISPLAY_ITEMS.sections.first()),
|
||||
)
|
||||
assertEquals(
|
||||
expectedState,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createViewModel() = ItemListingViewModel(
|
||||
authenticatorRepository = authenticatorRepository,
|
||||
authenticatorBridgeManager = authenticatorBridgeManager,
|
||||
|
||||
+3
@@ -124,9 +124,11 @@ private val SHARED_ITEMS = listOf(
|
||||
private val SHARED_DISPLAY_ITEMS = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "mockUserId-2",
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
"mockEmail-2",
|
||||
"mockkEnvironmentLabel-2",
|
||||
1,
|
||||
),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
@@ -142,6 +144,7 @@ private val SHARED_DISPLAY_ITEMS = SharedCodesDisplayState.Codes(
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+103
@@ -61,9 +61,11 @@ class SharedVerificationCodesStateTest {
|
||||
val expected = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "user1",
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
"John@test.com",
|
||||
"bitwarden.com",
|
||||
1,
|
||||
),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
@@ -79,11 +81,14 @@ class SharedVerificationCodesStateTest {
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
),
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "user1",
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
"Jane@test.com",
|
||||
"bitwarden.eu",
|
||||
1,
|
||||
),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
@@ -99,6 +104,7 @@ class SharedVerificationCodesStateTest {
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -107,6 +113,103 @@ class SharedVerificationCodesStateTest {
|
||||
state.toSharedCodesDisplayState(ALERT_THRESHOLD),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toSharedCodesDisplayState should return list of sections maintaining expanded state`() {
|
||||
val state = SharedVerificationCodesState.Success(
|
||||
items = listOf(
|
||||
VerificationCodeItem(
|
||||
code = "123456",
|
||||
periodSeconds = 30,
|
||||
timeLeftSeconds = 10,
|
||||
issueTime = 100L,
|
||||
id = "123",
|
||||
issuer = null,
|
||||
label = null,
|
||||
source = AuthenticatorItem.Source.Shared(
|
||||
userId = "user1",
|
||||
nameOfUser = "John Appleseed",
|
||||
email = "John@test.com",
|
||||
environmentLabel = "bitwarden.com",
|
||||
),
|
||||
),
|
||||
VerificationCodeItem(
|
||||
code = "987654",
|
||||
periodSeconds = 30,
|
||||
timeLeftSeconds = 10,
|
||||
issueTime = 100L,
|
||||
id = "987",
|
||||
issuer = "issuer",
|
||||
label = "accountName",
|
||||
source = AuthenticatorItem.Source.Shared(
|
||||
userId = "user1",
|
||||
nameOfUser = "Jane Doe",
|
||||
email = "Jane@test.com",
|
||||
environmentLabel = "bitwarden.eu",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val expected = SharedCodesDisplayState.Codes(
|
||||
sections = listOf(
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "user1",
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
"John@test.com",
|
||||
"bitwarden.com",
|
||||
1,
|
||||
),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
authCode = "123456",
|
||||
periodSeconds = 30,
|
||||
timeLeftSeconds = 10,
|
||||
id = "123",
|
||||
title = "--",
|
||||
subtitle = null,
|
||||
favorite = false,
|
||||
allowLongPressActions = false,
|
||||
alertThresholdSeconds = ALERT_THRESHOLD,
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = false,
|
||||
),
|
||||
SharedCodesDisplayState.SharedCodesAccountSection(
|
||||
id = "user1",
|
||||
label = R.string.shared_accounts_header.asText(
|
||||
"Jane@test.com",
|
||||
"bitwarden.eu",
|
||||
1,
|
||||
),
|
||||
codes = listOf(
|
||||
VerificationCodeDisplayItem(
|
||||
authCode = "987654",
|
||||
periodSeconds = 30,
|
||||
timeLeftSeconds = 10,
|
||||
id = "987",
|
||||
title = "issuer",
|
||||
subtitle = "accountName",
|
||||
favorite = false,
|
||||
allowLongPressActions = false,
|
||||
alertThresholdSeconds = ALERT_THRESHOLD,
|
||||
showMoveToBitwarden = false,
|
||||
),
|
||||
),
|
||||
isExpanded = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
expected,
|
||||
state.toSharedCodesDisplayState(
|
||||
alertThresholdSeconds = ALERT_THRESHOLD,
|
||||
currentSections = expected.sections.map {
|
||||
it.copy(label = "junk to show that it does update the other values".asText())
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val ALERT_THRESHOLD = 7
|
||||
|
||||
Reference in New Issue
Block a user