[PM-27246] Update Authenticator to use ActionCard (#2123)

This commit is contained in:
Matt Czech 2025-11-17 10:24:07 -06:00 committed by GitHub
parent 0bebf577b4
commit 0e2991802d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 159 additions and 257 deletions

View File

@ -1,27 +0,0 @@
// swiftlint:disable:this file_name
import BitwardenResources
import SnapshotTesting
import XCTest
@testable import AuthenticatorShared
// MARK: - ItemListCardViewTests
class ItemListCardViewTests: BitwardenTestCase {
// MARK: Tests
/// Test a snapshot of the ItemListView previews.
func disabletest_snapshot_ItemListCardView_previews() {
for preview in ItemListCardView_Previews._allPreviews {
let name = preview.displayName ?? "Unknown"
assertSnapshots(
of: preview.content,
as: [
"\(name)-portrait": .defaultPortrait,
"\(name)-portraitDark": .defaultPortraitDark,
"\(name)-portraitAX5": .tallPortraitAX5(heightMultiple: 3),
],
)
}
}
}

View File

@ -1,37 +0,0 @@
// swiftlint:disable:this file_name
import BitwardenResources
import ViewInspector
import XCTest
@testable import AuthenticatorShared
// MARK: - ItemListCardViewTests
class ItemListCardViewTests: BitwardenTestCase {
// MARK: Tests
/// Test the actions are properly wired up in the ItemListCardView.
func test_ItemListCardView_actions() throws {
let expectationAction = expectation(description: "action Tapped")
let expectationClose = expectation(description: "close Tapped")
let subject = ItemListCardView(
bodyText: Localizations
.allowAuthenticatorAppSyncingInSettingsToViewAllYourVerificationCodesHere,
buttonText: Localizations.takeMeToTheAppSettings,
leftImage: {},
titleText: Localizations.syncWithTheBitwardenApp,
actionTapped: {
expectationAction.fulfill()
},
closeTapped: {
expectationClose.fulfill()
},
)
try subject.inspect().find(buttonWithAccessibilityLabel: Localizations.close).tap()
wait(for: [expectationClose])
try subject.inspect().find(button: Localizations.takeMeToTheAppSettings).tap()
wait(for: [expectationAction])
}
}

View File

@ -1,136 +0,0 @@
import BitwardenResources
import SwiftUI
// MARK: - ItemListCardView
/// An item list card view,
///
struct ItemListCardView<ImageContent: View>: View {
// MARK: Properties
/// The body text to display in the card.
var bodyText: String
/// The button text to display in the card.
var buttonText: String
/// The image to display in the card.
@ViewBuilder let leftImage: ImageContent
/// The button text for the secondary button in the card.
var secondaryButtonText: String?
/// The title text to display in the card.
var titleText: String
// MARK: Closures
/// The callback action to perform.
var actionTapped: () -> Void
/// The close callback to perform.
var closeTapped: () -> Void
/// The action to perform when the secondary button is tapped.
var secondaryActionTapped: (() -> Void)?
var body: some View {
VStack(spacing: 16) {
HStack(alignment: .top, spacing: 16) {
leftImage
VStack(alignment: .leading, spacing: 0) {
Group {
Text(titleText)
.styleGuide(.headline)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
Text(bodyText)
.styleGuide(.subheadline)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
closeTapped()
} label: {
Image(decorative: SharedAsset.Icons.close16)
.padding(16) // Add padding to increase tappable area...
}
.padding(-16) // ...but remove it to not affect layout.
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(Localizations.close)
}
VStack(spacing: 0) {
Button {
actionTapped()
} label: {
Text(buttonText)
}
.buttonStyle(.primary())
if let secondaryButtonText, let secondaryActionTapped {
Button {
secondaryActionTapped()
} label: {
Text(secondaryButtonText)
}
.buttonStyle(.bitwardenBorderless)
.padding(.bottom, -8) // Remove extra padding below the borderless button.
}
}
}
.padding(16)
.background {
Asset.Colors.backgroundPrimary.swiftUIColor
.clipShape(.rect(cornerRadius: 16))
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
}
.frame(maxWidth: .infinity)
}
}
// MARK: Previews
#if DEBUG
struct ItemListCardView_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
ItemListCardView(
bodyText: Localizations
.allowAuthenticatorAppSyncingInSettingsToViewAllYourVerificationCodesHere,
buttonText: Localizations.takeMeToTheAppSettings,
leftImage: {
Image(decorative: SharedAsset.Icons.arrowSync24)
.foregroundColor(Asset.Colors.primaryBitwardenLight.swiftUIColor)
.frame(width: 24, height: 24)
},
titleText: Localizations.syncWithTheBitwardenApp,
actionTapped: {},
closeTapped: {},
)
ItemListCardView(
bodyText: Localizations
.allowAuthenticatorAppSyncingInSettingsToViewAllYourVerificationCodesHere,
buttonText: Localizations.takeMeToTheAppSettings,
leftImage: {
Image(decorative: SharedAsset.Icons.arrowSync24)
.foregroundColor(Asset.Colors.primaryBitwardenLight.swiftUIColor)
.frame(width: 24, height: 24)
},
secondaryButtonText: Localizations.learnMore,
titleText: Localizations.syncWithTheBitwardenApp,
actionTapped: {},
closeTapped: {},
secondaryActionTapped: {},
)
}
.padding(16)
}
}
}
#endif

View File

@ -43,7 +43,7 @@ class ItemListViewTests: BitwardenTestCase {
/// Test the close taps trigger the associated effect.
@MainActor
func test_itemListCardView_close_download() throws {
func test_actionCard_close_download() async throws {
let state = ItemListState(
itemListCardState: .passwordManagerDownload,
loadingState: .data([ItemListSection.fixture()]),
@ -54,16 +54,15 @@ class ItemListViewTests: BitwardenTestCase {
timeProvider: timeProvider,
)
try subject.inspect().find(buttonWithAccessibilityLabel: Localizations.close).tap()
waitFor(!processor.effects.isEmpty)
let actionCard = try subject.inspect().find(actionCard: Localizations.downloadTheBitwardenApp)
try await actionCard.find(asyncButton: Localizations.close).tap()
XCTAssertEqual(processor.effects.last, .closeCard(.passwordManagerDownload))
}
/// Test the close taps trigger the associated effect.
@MainActor
func test_itemListCardView_close_sync() throws {
func test_actionCard_close_sync() async throws {
let state = ItemListState(
itemListCardState: .passwordManagerSync,
loadingState: .data([]),
@ -74,9 +73,8 @@ class ItemListViewTests: BitwardenTestCase {
timeProvider: timeProvider,
)
try subject.inspect().find(buttonWithAccessibilityLabel: Localizations.close).tap()
waitFor(!processor.effects.isEmpty)
let actionCard = try subject.inspect().find(actionCard: Localizations.syncWithTheBitwardenApp)
try await actionCard.find(asyncButton: Localizations.close).tap()
XCTAssertEqual(processor.effects.last, .closeCard(.passwordManagerSync))
}

View File

@ -7,7 +7,7 @@ import SwiftUI
// MARK: - SearchableItemListView
/// A view that displays the items in a single vault group.
private struct SearchableItemListView: View { // swiftlint:disable:this type_body_length
private struct SearchableItemListView: View {
// MARK: Properties
/// A flag indicating if the search bar is focused.
@ -131,52 +131,42 @@ private struct SearchableItemListView: View { // swiftlint:disable:this type_bod
/// The Password Manager download card definition.
private var itemListCardPasswordManagerInstall: some View {
ItemListCardView(
bodyText: Localizations.storeAllOfYourLoginsAndSyncVerificationCodesDirectlyWithTheAuthenticatorApp,
buttonText: Localizations.downloadTheBitwardenApp,
leftImage: {
Image(decorative: SharedAsset.Icons.shield24)
.foregroundColor(Asset.Colors.primaryBitwardenLight.swiftUIColor)
.frame(width: 24, height: 24)
},
titleText: Localizations.downloadTheBitwardenApp,
actionTapped: {
ActionCard(
title: Localizations.downloadTheBitwardenApp,
message: Localizations.storeAllOfYourLoginsAndSyncVerificationCodesDirectlyWithTheAuthenticatorApp,
actionButtonState: ActionCard.ButtonState(title: Localizations.downloadTheBitwardenApp) {
openURL(ExternalLinksConstants.passwordManagerLink)
},
closeTapped: {
Task {
await store.perform(.closeCard(.passwordManagerDownload))
}
dismissButtonState: ActionCard.ButtonState(title: Localizations.close) {
await store.perform(.closeCard(.passwordManagerDownload))
},
)
) {
Image(decorative: SharedAsset.Icons.shield24)
.foregroundColor(SharedAsset.Colors.iconSecondary.swiftUIColor)
.frame(width: 24, height: 24)
}
.padding(.top, 16)
}
/// The Password Manager sync card definition.
private var itemListCardSync: some View {
ItemListCardView(
bodyText: Localizations
.allowAuthenticatorAppSyncingInSettingsToViewAllYourVerificationCodesHere,
buttonText: Localizations.takeMeToTheAppSettings,
leftImage: {
Image(decorative: SharedAsset.Icons.arrowSync24)
.foregroundColor(Asset.Colors.primaryBitwardenLight.swiftUIColor)
.frame(width: 24, height: 24)
},
secondaryButtonText: Localizations.learnMore,
titleText: Localizations.syncWithTheBitwardenApp,
actionTapped: {
ActionCard(
title: Localizations.syncWithTheBitwardenApp,
message: Localizations.allowAuthenticatorAppSyncingInSettingsToViewAllYourVerificationCodesHere,
actionButtonState: ActionCard.ButtonState(title: Localizations.takeMeToTheAppSettings) {
openURL(ExternalLinksConstants.passwordManagerSettings)
},
closeTapped: {
Task {
await store.perform(.closeCard(.passwordManagerSync))
}
dismissButtonState: ActionCard.ButtonState(title: Localizations.close) {
await store.perform(.closeCard(.passwordManagerSync))
},
secondaryActionTapped: {
secondaryButtonState: ActionCard.ButtonState(title: Localizations.learnMore) {
openURL(ExternalLinksConstants.totpSyncHelp)
},
)
) {
Image(decorative: SharedAsset.Icons.arrowSync24)
.foregroundColor(SharedAsset.Colors.iconSecondary.swiftUIColor)
.frame(width: 24, height: 24)
}
.padding(.top, 16)
}
@ -722,6 +712,36 @@ struct ItemListView_Previews: PreviewProvider { // swiftlint:disable:this type_b
timeProvider: PreviewTimeProvider(),
)
}.previewDisplayName("SharedItems")
NavigationView {
ItemListView(
store: Store(
processor: StateProcessor(
state: ItemListState(
itemListCardState: .passwordManagerDownload,
loadingState: .data([]),
),
),
),
timeProvider: PreviewTimeProvider(),
)
}
.previewDisplayName("Password Manager Download Card")
NavigationView {
ItemListView(
store: Store(
processor: StateProcessor(
state: ItemListState(
itemListCardState: .passwordManagerSync,
loadingState: .data([]),
),
),
),
timeProvider: PreviewTimeProvider(),
)
}
.previewDisplayName("Password Manager Sync Card")
}
}
#endif

View File

@ -18,12 +18,22 @@ public struct BitwardenBorderlessButtonStyle: ButtonStyle {
: SharedAsset.Colors.buttonOutlinedDisabledForeground.swiftUIColor
}
/// If this button should fill to take up as much width as possible.
var shouldFillWidth = false
/// The size of the button.
var size: ButtonStyleSize?
// MARK: ButtonStyle
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(foregroundColor)
.styleGuide(.subheadlineSemibold)
.styleGuide(size?.fontStyle ?? .subheadlineSemibold)
.padding(.vertical, size?.verticalPadding ?? 0)
.padding(.horizontal, size?.horizontalPadding ?? 0)
.frame(maxWidth: shouldFillWidth ? .infinity : nil, minHeight: size?.minimumHeight ?? nil)
.contentShape(Capsule())
.opacity(configuration.isPressed ? 0.5 : 1)
}
}
@ -33,15 +43,31 @@ public struct BitwardenBorderlessButtonStyle: ButtonStyle {
public extension ButtonStyle where Self == BitwardenBorderlessButtonStyle {
/// The style for a borderless button in this application.
///
/// This style does not add any padding to the button. Padding should be applied by the caller.
///
static var bitwardenBorderless: BitwardenBorderlessButtonStyle {
BitwardenBorderlessButtonStyle()
}
/// The style for a borderless button in this application with padding and font size based on
/// the button size.
///
/// - Parameters:
/// - shouldFillWidth: A flag indicating if this button should fill all available space.
/// - size: The size of the button, which determines the padding and font size applied.
///
static func bitwardenBorderless(
shouldFillWidth: Bool = true,
size: ButtonStyleSize,
) -> BitwardenBorderlessButtonStyle {
BitwardenBorderlessButtonStyle(shouldFillWidth: shouldFillWidth, size: size)
}
}
// MARK: Previews
#if DEBUG
#Preview() {
#Preview("States") {
VStack {
Button("Bitwarden") {}
@ -49,6 +75,20 @@ public extension ButtonStyle where Self == BitwardenBorderlessButtonStyle {
.disabled(true)
}
.buttonStyle(.bitwardenBorderless)
.padding(.vertical, 14)
.padding()
}
#Preview("Sizes") {
VStack {
Button("Small") {}
.buttonStyle(.bitwardenBorderless(size: .small))
Button("Medium") {}
.buttonStyle(.bitwardenBorderless(size: .medium))
Button("Large") {}
.buttonStyle(.bitwardenBorderless(size: .large))
}
.padding()
}
#endif

View File

@ -1,8 +1,9 @@
// swiftlint:disable:this file_name
import SwiftUI
import ViewInspectorTestHelpers
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
final class ActionCardTests: BitwardenTestCase {
// MARK: Tests
@ -38,4 +39,22 @@ final class ActionCardTests: BitwardenTestCase {
XCTAssertTrue(dismissButtonTapped)
}
/// Tapping the secondary button should call the secondary button state's action closure.
@MainActor
func test_secondaryButton_tap() async throws {
var secondaryButtonTapped = false
let subject = ActionCard(
title: "Title",
message: "Message",
secondaryButtonState: ActionCard.ButtonState(title: "Secondary") {
secondaryButtonTapped = true
},
)
let button = try subject.inspect().find(asyncButton: "Secondary")
try await button.tap()
XCTAssertTrue(secondaryButtonTapped)
}
}

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenResources
import SwiftUI
@ -6,12 +5,12 @@ import SwiftUI
/// A view that displays a card representing an action that the user needs to take.
///
struct ActionCard<LeadingContent: View>: View {
public struct ActionCard<LeadingContent: View>: View {
// MARK: Types
/// A data model containing the properties for a button within an action card.
///
struct ButtonState {
public struct ButtonState {
// MARK: Properties
/// An action to perform when the button is tapped.
@ -28,7 +27,7 @@ struct ActionCard<LeadingContent: View>: View {
/// - title: The title of the button.
/// - action: An action to perform when the button is tapped.
///
init(title: String, action: @escaping () async -> Void) {
public init(title: String, action: @escaping () async -> Void) {
self.action = action
self.title = title
}
@ -48,12 +47,15 @@ struct ActionCard<LeadingContent: View>: View {
/// The message to display in the card, below the title.
let message: String?
/// State that describes the secondary button.
let secondaryButtonState: ButtonState?
/// The title of the card.
let title: String
// MARK: View
var body: some View {
public var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 8) {
if let leadingContent {
@ -82,9 +84,18 @@ struct ActionCard<LeadingContent: View>: View {
}
}
if let actionButtonState {
AsyncButton(actionButtonState.title, action: actionButtonState.action)
.buttonStyle(.primary(size: .medium))
if actionButtonState != nil || secondaryButtonState != nil {
VStack(spacing: 4) {
if let actionButtonState {
AsyncButton(actionButtonState.title, action: actionButtonState.action)
.buttonStyle(.primary(size: .medium))
}
if let secondaryButtonState {
AsyncButton(secondaryButtonState.title, action: secondaryButtonState.action)
.buttonStyle(.bitwardenBorderless(size: .medium))
}
}
}
}
.foregroundStyle(SharedAsset.Colors.textPrimary.swiftUIColor)
@ -108,19 +119,22 @@ struct ActionCard<LeadingContent: View>: View {
/// - message: The message to display in the card.
/// - actionButtonState: State that describes the action button.
/// - dismissButtonState: State that describes the dismiss button.
/// - secondaryButtonState: State that describes the secondary button.
/// - leadingContent: Content that is displayed at the leading edge of the title and message.
///
init(
public init(
title: String,
message: String? = nil,
actionButtonState: ButtonState? = nil,
dismissButtonState: ButtonState? = nil,
secondaryButtonState: ButtonState? = nil,
@ViewBuilder leadingContent: () -> LeadingContent,
) {
self.actionButtonState = actionButtonState
self.dismissButtonState = dismissButtonState
self.leadingContent = leadingContent()
self.message = message
self.secondaryButtonState = secondaryButtonState
self.title = title
}
@ -131,17 +145,20 @@ struct ActionCard<LeadingContent: View>: View {
/// - message: The message to display in the card.
/// - actionButtonState: State that describes the action button.
/// - dismissButtonState: State that describes the dismiss button.
/// - secondaryButtonState: State that describes the secondary button.
///
init(
public init(
title: String,
message: String? = nil,
actionButtonState: ButtonState? = nil,
dismissButtonState: ButtonState? = nil,
secondaryButtonState: ButtonState? = nil,
) where LeadingContent == EmptyView {
self.actionButtonState = actionButtonState
self.dismissButtonState = dismissButtonState
leadingContent = nil
self.message = message
self.secondaryButtonState = secondaryButtonState
self.title = title
}
}
@ -163,6 +180,14 @@ struct ActionCard<LeadingContent: View>: View {
dismissButtonState: ActionCard.ButtonState(title: "Dismiss") {},
)
ActionCard(
title: "Title",
message: "Message",
actionButtonState: ActionCard.ButtonState(title: "Tap me!") {},
dismissButtonState: ActionCard.ButtonState(title: "Dismiss") {},
secondaryButtonState: ActionCard.ButtonState(title: "Secondary button") {},
)
ActionCard(
title: "Title",
message: "Message",

View File

@ -12,7 +12,7 @@ public struct ActionCardType: BaseViewType {
public static var typePrefix: String = "ActionCard"
public static var namespacedPrefixes: [String] = [
"BitwardenShared.ActionCard",
"BitwardenKit.ActionCard",
]
}