diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index f144511a5e..d30d8e93c9 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -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 = { }, + ) + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index daad6c98a9..dc668422dd 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -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. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/model/SharedCodesDisplayState.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/model/SharedCodesDisplayState.kt index 491464386a..adc813b4cd 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/model/SharedCodesDisplayState.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/model/SharedCodesDisplayState.kt @@ -26,8 +26,10 @@ sealed class SharedCodesDisplayState : Parcelable { */ @Parcelize data class SharedCodesAccountSection( + val id: String, val label: Text, val codes: List, + val isExpanded: Boolean, ) : Parcelable /** diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt index 551bd73e53..5c5bbdd869 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt @@ -12,6 +12,7 @@ import com.bitwarden.ui.util.asText */ fun SharedVerificationCodesState.Success.toSharedCodesDisplayState( alertThresholdSeconds: Int, + currentSections: List = emptyList(), ): SharedCodesDisplayState.Codes { val codesMap = mutableMapOf>() @@ -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) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/AuthenticatorExpandingHeader.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/AuthenticatorExpandingHeader.kt new file mode 100644 index 0000000000..b7bb16ea86 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/header/AuthenticatorExpandingHeader.kt @@ -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 = "", + ) + } +} diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml index 0c6fb9edac..ffb8960325 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -118,7 +118,7 @@ Sync with Bitwarden app This feature is not yet available for self-hosted users. Learn more 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. - %1$s | %2$s + %1$s | %2$s (%3$d) Sync with the Bitwarden app In order to view all of your verification codes, you’ll need to allow for syncing on all of your accounts. Take me to the app settings @@ -135,11 +135,15 @@ Save this authenticator key here, or add it to a login in your Bitwarden app. Save option as default Account synced from Bitwarden app - Local codes + Local codes (%1$d) Required Information Missing "Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support" "File Could Not Be Processed" "File could not be processed. Ensure it’s valid JSON and try again. Need help? Visit bitwarden.com/help" Get Help Expand advanced options + Local items are expanded, click to collapse. + Local items are collapsed, click to expand. + Items are expanded, click to collapse. + Items are collapsed, click to expand. diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt index d6ab372180..4b7edf173f 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -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( diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt index e744bf6604..a40fc78f5b 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt @@ -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, diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt index dcf53d9e61..29268e3270 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt @@ -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, ), ), ) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt index 73ce0597c5..c640d957b0 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt @@ -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