From 90b669948a94dbf79d6ab57ff365b36ad516a169 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Thu, 25 Apr 2024 20:25:15 -0400 Subject: [PATCH] Adjust top app bar --- .../feature/itemlisting/ItemListingScreen.kt | 418 +++++++++++------- .../action/BitwardenSearchActionItem.kt | 45 ++ .../components/util/RememberVectorPainter.kt | 19 + 3 files changed, 322 insertions(+), 160 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index c4774f955..f820dd4ca 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -6,7 +6,6 @@ import android.net.Uri import android.provider.Settings import android.widget.Toast import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -18,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -31,7 +30,6 @@ 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.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -48,7 +46,9 @@ import com.bitwarden.authenticator.R import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingExpandableFabAction import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.action.BitwardenSearchActionItem import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog @@ -57,8 +57,6 @@ 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.icon.BitwardenIcon -import com.bitwarden.authenticator.ui.platform.components.model.IconData import com.bitwarden.authenticator.ui.platform.components.model.IconResource import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme @@ -155,25 +153,153 @@ fun ItemListingScreen( }, ) + when (val currentState = state.viewState) { + is ItemListingState.ViewState.Content -> { + ItemListingContent( + currentState, + scrollBehavior, + onNavigateToSearch = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.SearchClick + ) + } + }, + onScanQrCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + }, + onItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.ItemClick(it) + ) + } + }, + onEditItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.EditItemClick(it) + ) + } + }, + onDeleteItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.DeleteItemClick(it) + ) + } + } + ) + } + + is ItemListingState.ViewState.Error -> { + Text( + text = "Error! ${currentState.message}", + modifier = Modifier.fillMaxSize(), + ) + } + + ItemListingState.ViewState.Loading, + ItemListingState.ViewState.NoItems, + -> { + EmptyItemListingContent( + appTheme = state.appTheme, + scrollBehavior = scrollBehavior, + onAddCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onScanQuCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + } + ) + } + } +} + +@Composable +private fun ItemListingDialogs( + dialog: ItemListingState.DialogState?, + onDismissRequest: () -> Unit, + onConfirmDeleteClick: (itemId: String) -> Unit, +) { + when (dialog) { + ItemListingState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = R.string.syncing.asText(), + ), + ) + } + + is ItemListingState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = onDismissRequest, + ) + } + + is ItemListingState.DialogState.DeleteConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = dialog.message(), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onConfirmDeleteClick(dialog.itemId) + }, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest + ) + } + + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemListingContent( + state: ItemListingState.ViewState.Content, + scrollBehavior: TopAppBarScrollBehavior, + onNavigateToSearch: () -> Unit, + onScanQrCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, + onItemClick: (String) -> Unit, + onEditItemClick: (String) -> Unit, + onDeleteItemClick: (String) -> Unit, +) { BitwardenScaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - BitwardenTopAppBar( + BitwardenMediumTopAppBar( title = stringResource(id = R.string.verification_codes), scrollBehavior = scrollBehavior, - navigationIcon = null, actions = { - if (state.viewState !is ItemListingState.ViewState.NoItems) { - BitwardenIcon( - modifier = Modifier.clickable { - viewModel.trySendAction(ItemListingAction.SearchClick) - }, - iconData = IconData.Local(R.drawable.ic_search_24px), - tint = MaterialTheme.colorScheme.surfaceTint - ) - } + BitwardenSearchActionItem( + contentDescription = stringResource(id = R.string.search_codes), + onClick = onNavigateToSearch, + ) } ) }, @@ -191,9 +317,7 @@ fun ItemListingScreen( contentDescription = stringResource(id = R.string.scan_a_qr_code), testTag = "ScanQRCodeButton", ), - onScanQrCodeClick = { - launcher.launch(Manifest.permission.CAMERA) - } + onScanQrCodeClick = onScanQrCodeClick, ), ItemListingExpandableFabAction.EnterSetupKey( label = R.string.enter_a_setup_key.asText(), @@ -202,9 +326,7 @@ fun ItemListingScreen( contentDescription = stringResource(id = R.string.enter_a_setup_key), testTag = "EnterSetupKeyButton", ), - onEnterSetupKeyClick = { - viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) - } + onEnterSetupKeyClick = onEnterSetupKeyClick ) ), expandableFabIcon = ExpandableFabIcon( @@ -224,64 +346,22 @@ fun ItemListingScreen( .fillMaxSize() .padding(paddingValues), ) { - when (val currentState = state.viewState) { - is ItemListingState.ViewState.Content -> { - LazyColumn { - items(currentState.itemList) { - VaultVerificationCodeItem( - authCode = it.authCode, - name = it.issuer, - username = it.username, - periodSeconds = it.periodSeconds, - timeLeftSeconds = it.timeLeftSeconds, - alertThresholdSeconds = it.alertThresholdSeconds, - startIcon = it.startIcon, - onItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.ItemClick(it.authCode) - ) - } - }, - onEditItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.EditItemClick(it.id) - ) - } - }, - onDeleteItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.DeleteItemClick(it.id) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - } - } - } - - is ItemListingState.ViewState.Error -> { - Text( - text = "Error! ${currentState.message}", - modifier = Modifier.fillMaxSize(), - ) - } - - ItemListingState.ViewState.NoItems, - ItemListingState.ViewState.Loading, - -> { - EmptyItemListingContent( - appTheme = state.appTheme, - onAddCodeClick = remember(viewModel) { - { - launcher.launch(Manifest.permission.CAMERA) - } - }, + LazyColumn { + items(state.itemList) { + VaultVerificationCodeItem( + authCode = it.authCode, + name = it.issuer, + username = it.username, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onEditItemClick = { onEditItemClick(it.id) }, + onDeleteItemClick = { onDeleteItemClick(it.id) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) } } @@ -289,106 +369,124 @@ fun ItemListingScreen( } } -@Composable -private fun ItemListingDialogs( - dialog: ItemListingState.DialogState?, - onDismissRequest: () -> Unit, - onConfirmDeleteClick: (itemId: String) -> Unit, -) { - when (dialog) { - ItemListingState.DialogState.Loading -> { - BitwardenLoadingDialog( - visibilityState = LoadingDialogState.Shown( - text = R.string.syncing.asText(), - ), - ) - } - - is ItemListingState.DialogState.Error -> { - BitwardenBasicDialog( - visibilityState = BasicDialogState.Shown( - title = dialog.title, - message = dialog.message, - ), - onDismissRequest = onDismissRequest, - ) - } - - is ItemListingState.DialogState.DeleteConfirmationPrompt -> { - BitwardenTwoButtonDialog( - title = stringResource(id = R.string.delete), - message = dialog.message(), - confirmButtonText = stringResource(id = R.string.ok), - dismissButtonText = stringResource(id = R.string.cancel), - onConfirmClick = { - onConfirmDeleteClick(dialog.itemId) - }, - onDismissClick = onDismissRequest, - onDismissRequest = onDismissRequest - ) - } - - null -> Unit - } -} - /** * Displays the item listing screen with no existing items. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EmptyItemListingContent( modifier: Modifier = Modifier, appTheme: AppTheme, - onAddCodeClick: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState() + ), + onAddCodeClick: () -> Unit, + onScanQuCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, ) { - Column( - modifier = modifier + BitwardenScaffold( + modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.verification_codes), + scrollBehavior = scrollBehavior, + navigationIcon = null, + actions = { } + ) + }, + floatingActionButton = { + ExpandableFloatingActionButton( + modifier = Modifier + .semantics { testTag = "AddItemButton" } + .padding(bottom = 16.dp), + label = R.string.add_item.asText(), + items = listOf( + ItemListingExpandableFabAction.ScanQrCode( + label = R.string.scan_a_qr_code.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_camera), + contentDescription = stringResource(id = R.string.scan_a_qr_code), + testTag = "ScanQRCodeButton", + ), + onScanQrCodeClick = onScanQuCodeClick + ), + ItemListingExpandableFabAction.EnterSetupKey( + label = R.string.enter_a_setup_key.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_keyboard_24px), + contentDescription = stringResource(id = R.string.enter_a_setup_key), + testTag = "EnterSetupKeyButton", + ), + onEnterSetupKeyClick = onEnterSetupKeyClick, + ) + ), + expandableFabIcon = ExpandableFabIcon( + iconData = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_item), + testTag = "AddItemButton", + ), + iconRotation = 45f, + ), + ) + }, + floatingActionButtonPosition = FabPosition.EndOverlay, ) { - Image( - modifier = Modifier.fillMaxWidth(), - painter = painterResource( - id = when (appTheme) { - AppTheme.DARK -> R.drawable.ic_empty_vault_dark - AppTheme.LIGHT -> R.drawable.ic_empty_vault_light - AppTheme.DEFAULT -> R.drawable.ic_empty_vault - } - ), - contentDescription = stringResource( - id = R.string.empty_item_list, - ), - contentScale = ContentScale.Fit, - ) + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.fillMaxWidth(), + painter = painterResource( + id = when (appTheme) { + AppTheme.DARK -> R.drawable.ic_empty_vault_dark + AppTheme.LIGHT -> R.drawable.ic_empty_vault_light + AppTheme.DEFAULT -> R.drawable.ic_empty_vault + } + ), + contentDescription = stringResource( + id = R.string.empty_item_list, + ), + contentScale = ContentScale.Fit, + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.you_dont_have_items_to_display), - style = Typography.titleMedium, - ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.you_dont_have_items_to_display), + style = Typography.titleMedium, + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - textAlign = TextAlign.Center, - text = stringResource(id = R.string.empty_item_list_instruction), - ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + textAlign = TextAlign.Center, + text = stringResource(id = R.string.empty_item_list_instruction), + ) - Spacer(modifier = Modifier.height(16.dp)) - BitwardenFilledTonalButton( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.add_code), - onClick = onAddCodeClick, - ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledTonalButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.add_code), + onClick = onAddCodeClick, + ) + } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable @Preview(showBackground = true) fun EmptyListingContentPreview() { EmptyItemListingContent( - appTheme = AppTheme.DEFAULT, modifier = Modifier.padding(horizontal = 16.dp), + appTheme = AppTheme.DEFAULT, + onAddCodeClick = { }, + onScanQuCodeClick = { }, + onEnterSetupKeyClick = { }, ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt new file mode 100644 index 000000000..dc5cba902 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar.action + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * Represents the Bitwarden search action item. + * + * This is an [Icon] composable tailored specifically for the search functionality + * in the Bitwarden app. + * It presents the search icon and offers an `onClick` callback for when the icon is tapped. + * + * @param contentDescription A description of the UI element, used for accessibility purposes. + * @param onClick A callback to be invoked when this action item is clicked. + */ +@Composable +fun BitwardenSearchActionItem( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier.testTag("SearchButton"), + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_search_24px), + contentDescription = contentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenSearchActionItem_preview() { + BitwardenSearchActionItem( + contentDescription = "Search", + onClick = {}, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt new file mode 100644 index 000000000..1f6345947 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.ui.platform.components.util + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource + +/** + * Returns a [VectorPainter] built from the given [id] to circumvent issues with painter resources + * recomposing unnecessarily. + */ +@Composable +fun rememberVectorPainter( + @DrawableRes id: Int, +): VectorPainter = rememberVectorPainter( + image = ImageVector.vectorResource(id), +)