[PM-26063] Update Authenticator's settings view to latest designs (#2113)

This commit is contained in:
Matt Czech 2025-11-06 16:40:22 -06:00 committed by GitHub
parent f9ae28de1d
commit 73fc2d84b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 122 additions and 365 deletions

View File

@ -60,7 +60,7 @@ class SettingsViewTests: BitwardenTestCase {
func test_defaultSaveOptionChanged_updateValue() throws {
processor.state.shouldShowDefaultSaveOption = true
processor.state.defaultSaveOption = .none
let menuField = try subject.inspect().find(settingsMenuField: Localizations.defaultSaveOption)
let menuField = try subject.inspect().find(bitwardenMenuField: Localizations.defaultSaveOption)
try menuField.select(newValue: DefaultSaveOption.saveToBitwarden)
XCTAssertEqual(processor.dispatchedActions.last, .defaultSaveChanged(.saveToBitwarden))
}
@ -102,7 +102,7 @@ class SettingsViewTests: BitwardenTestCase {
func test_sessionTimeoutValue_updateValue() throws {
processor.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: true)
processor.state.sessionTimeoutValue = .never
let menuField = try subject.inspect().find(settingsMenuField: Localizations.sessionTimeout)
let menuField = try subject.inspect().find(bitwardenMenuField: Localizations.sessionTimeout)
try menuField.select(newValue: SessionTimeoutValue.fifteenMinutes)
waitFor(!processor.effects.isEmpty)

View File

@ -27,59 +27,54 @@ struct SettingsView: View {
// MARK: View
var body: some View {
settingsItems
.scrollView()
.navigationBar(title: Localizations.settings, titleDisplayMode: titleDisplayMode)
.toast(store.binding(
get: \.toast,
send: SettingsAction.toastShown,
))
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearURL)
}
.task {
await store.perform(.loadData)
}
VStack(spacing: 16) {
securitySection
dataSection
appearanceSection
helpSection
aboutSection
copyrightNotice
}
.scrollView()
.navigationBar(title: Localizations.settings, titleDisplayMode: titleDisplayMode)
.toast(store.binding(
get: \.toast,
send: SettingsAction.toastShown,
))
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearURL)
}
.task {
await store.perform(.loadData)
}
}
// MARK: Private views
/// A view for the user's biometrics setting
///
@ViewBuilder private var biometricsSetting: some View {
switch store.state.biometricUnlockStatus {
case let .available(type, enabled: enabled, _):
SectionView(Localizations.security) {
VStack(spacing: 8) {
biometricUnlockToggle(enabled: enabled, type: type)
SettingsMenuField(
title: Localizations.sessionTimeout,
options: SessionTimeoutValue.allCases,
hasDivider: false,
accessibilityIdentifier: "VaultTimeoutChooser",
selectionAccessibilityID: "SessionTimeoutStatusLabel",
selection: store.bindingAsync(
get: \.sessionTimeoutValue,
perform: SettingsEffect.sessionTimeoutValueChanged,
),
)
.clipShape(RoundedRectangle(cornerRadius: 10))
/// The about section containing privacy policy and version information.
@ViewBuilder private var aboutSection: some View {
SectionView(Localizations.about) {
ContentBlock(dividerLeadingPadding: 16) {
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
SettingsListItem(store.state.version) {
store.send(.versionTapped)
} trailingContent: {
SharedAsset.Icons.copy24.swiftUIImage
.imageStyle(.rowIcon)
}
}
.padding(.bottom, 32)
default:
EmptyView()
}
}
/// The chevron shown in the settings list item.
private var chevron: some View {
Image(asset: SharedAsset.Icons.chevronRight16)
.resizable()
.scaledFrame(width: 12, height: 12)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
/// The appearance section containing language and theme settings.
@ViewBuilder private var appearanceSection: some View {
SectionView(Localizations.appearance, contentSpacing: 8) {
language
theme
}
}
/// The copyright notice.
@ -91,31 +86,9 @@ struct SettingsView: View {
.frame(maxWidth: .infinity)
}
/// The language picker view
private var language: some View {
Button {
store.send(.languageTapped)
} label: {
BitwardenField(
title: Localizations.language,
footer: Localizations.languageChangeRequiresAppRestart,
) {
Text(store.state.currentLanguage.title)
.styleGuide(.body)
.foregroundColor(Color(asset: SharedAsset.Colors.textPrimary))
.multilineTextAlignment(.leading)
} accessoryContent: {
SharedAsset.Icons.chevronDown24.swiftUIImage
.imageStyle(.rowIcon)
}
}
}
/// The settings items.
private var settingsItems: some View {
VStack(spacing: 0) {
biometricsSetting
/// The data section containing import, export, backup, and sync options.
@ViewBuilder private var dataSection: some View {
SectionView(Localizations.data) {
ContentBlock(dividerLeadingPadding: 16) {
SettingsListItem(Localizations.import) {
store.send(.importItemsTapped)
@ -135,14 +108,12 @@ struct SettingsView: View {
defaultSaveOption
}
}
.padding(.bottom, 32)
SectionView(Localizations.appearance) {
language
theme
}
.padding(.bottom, 32)
}
}
/// The help section containing tutorial and help center links.
@ViewBuilder private var helpSection: some View {
SectionView(Localizations.help) {
ContentBlock(dividerLeadingPadding: 16) {
SettingsListItem(Localizations.launchTutorial) {
store.send(.tutorialTapped)
@ -150,31 +121,34 @@ struct SettingsView: View {
externalLinkRow(Localizations.bitwardenHelpCenter, action: .helpCenterTapped)
}
.padding(.bottom, 32)
ContentBlock(dividerLeadingPadding: 16) {
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
SettingsListItem(store.state.version) {
store.send(.versionTapped)
} trailingContent: {
SharedAsset.Icons.copy24.swiftUIImage
.imageStyle(.rowIcon)
}
}
.padding(.bottom, 16)
copyrightNotice
}
.cornerRadius(10)
}
/// The application's default save option picker view
/// The language picker view.
private var language: some View {
Button {
store.send(.languageTapped)
} label: {
BitwardenField(
title: Localizations.language,
footer: Localizations.languageChangeRequiresAppRestart,
) {
Text(store.state.currentLanguage.title)
.styleGuide(.body)
.foregroundColor(Color(asset: SharedAsset.Colors.textPrimary))
.multilineTextAlignment(.leading)
} accessoryContent: {
SharedAsset.Icons.chevronDown24.swiftUIImage
.imageStyle(.rowIcon)
}
}
}
/// The application's default save option picker view.
@ViewBuilder private var defaultSaveOption: some View {
SettingsMenuField(
BitwardenMenuField(
title: Localizations.defaultSaveOption,
options: DefaultSaveOption.allCases,
hasDivider: false,
selection: store.binding(
get: \.defaultSaveOption,
send: SettingsAction.defaultSaveChanged,
@ -183,7 +157,31 @@ struct SettingsView: View {
.accessibilityIdentifier("DefaultSaveOptionChooser")
}
/// The application's color theme picker view
/// The security section containing biometric unlock and session timeout settings.
@ViewBuilder private var securitySection: some View {
switch store.state.biometricUnlockStatus {
case let .available(type, enabled: enabled, _):
SectionView(Localizations.security) {
ContentBlock {
biometricUnlockToggle(enabled: enabled, type: type)
BitwardenMenuField(
title: Localizations.sessionTimeout,
accessibilityIdentifier: "VaultTimeoutChooser",
options: SessionTimeoutValue.allCases,
selection: store.bindingAsync(
get: \.sessionTimeoutValue,
perform: SettingsEffect.sessionTimeoutValueChanged,
),
)
}
}
default:
EmptyView()
}
}
/// The application's color theme picker view.
private var theme: some View {
BitwardenMenuField(
title: Localizations.theme,
@ -202,16 +200,15 @@ struct SettingsView: View {
@ViewBuilder
private func biometricUnlockToggle(enabled: Bool, type: BiometricAuthenticationType) -> some View {
let toggleText = biometricsToggleText(type)
Toggle(isOn: store.bindingAsync(
get: { _ in enabled },
perform: SettingsEffect.toggleUnlockWithBiometrics,
)) {
Text(toggleText)
}
.padding(.trailing, 3)
BitwardenToggle(
toggleText,
isOn: store.bindingAsync(
get: { _ in enabled },
perform: SettingsEffect.toggleUnlockWithBiometrics,
),
)
.accessibilityIdentifier("UnlockWithBiometricsSwitch")
.accessibilityLabel(toggleText)
.toggleStyle(.bitwarden)
}
private func biometricsToggleText(_ biometryType: BiometricAuthenticationType) -> String {

View File

@ -120,10 +120,11 @@ public struct BitwardenMenuField<
)
.foregroundColor(isEnabled
? SharedAsset.Colors.textSecondary.swiftUIColor
: SharedAsset.Colors.buttonFilledDisabledForeground.swiftUIColor)
.onSizeChanged { size in
titleWidth = size.width
}
: SharedAsset.Colors.buttonFilledDisabledForeground.swiftUIColor
)
.onSizeChanged { size in
titleWidth = size.width
}
}
Text(selection.localizedName)
@ -150,20 +151,21 @@ public struct BitwardenMenuField<
.styleGuide(.body)
.foregroundColor(isEnabled
? SharedAsset.Colors.textPrimary.swiftUIColor
: SharedAsset.Colors.buttonFilledDisabledForeground.swiftUIColor)
.frame(minHeight: 64)
.accessibilityIdentifier(accessibilityIdentifier ?? "")
.overlay {
if let titleAccessoryContent {
titleAccessoryContent
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading,
)
.offset(x: titleWidth + 4, y: 12)
}
: SharedAsset.Colors.buttonFilledDisabledForeground.swiftUIColor
)
.frame(minHeight: 64)
.accessibilityIdentifier(accessibilityIdentifier ?? "")
.overlay {
if let titleAccessoryContent {
titleAccessoryContent
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading,
)
.offset(x: titleWidth + 4, y: 12)
}
}
}
// MARK: Initialization

View File

@ -1,60 +0,0 @@
// swiftlint:disable:this file_name
import BitwardenKit
import SwiftUI
import ViewInspector
import XCTest
class SettingsMenuFieldTests: BitwardenTestCase {
// MARK: Types
enum TestValue: String, CaseIterable, Menuable {
case value1
case value2
var localizedName: String {
rawValue
}
}
// MARK: Properties
var selection: TestValue!
var subject: SettingsMenuField<TestValue>!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
selection = .value1
let binding = Binding {
self.selection!
} set: { newValue in
self.selection = newValue
}
subject = SettingsMenuField(
title: "Title",
options: TestValue.allCases,
selection: binding,
)
}
override func tearDown() {
super.tearDown()
selection = nil
subject = nil
}
// MARK: Tests
func test_newSelection() throws {
let picker = try subject.inspect().find(ViewType.Picker.self)
try picker.select(value: TestValue.value2)
XCTAssertEqual(selection, .value2)
let menu = try subject.inspect().find(ViewType.Menu.self)
let title = try menu.labelView().find(ViewType.Text.self).string()
let pickerValue = try menu.find(ViewType.HStack.self).find(text: "value2").string()
XCTAssertEqual(title, "Title")
XCTAssertEqual(pickerValue, "value2")
}
}

View File

@ -1,147 +0,0 @@
import BitwardenResources
import SwiftUI
// MARK: - SettingsMenuField
/// A standard input field that allows the user to select between a predefined set of
/// options.
///
public struct SettingsMenuField<T>: View where T: Menuable {
// MARK: Properties
/// The accessibility ID for the menu field.
let accessibilityIdentifier: String?
/// Whether the menu field should have a bottom divider.
let hasDivider: Bool
/// Whether the view allows user interaction.
@Environment(\.isEnabled) var isEnabled: Bool
/// The selection chosen from the menu.
@Binding var selection: T
/// The accessibility ID for the picker selection.
let selectionAccessibilityID: String?
/// The options displayed in the menu.
let options: [T]
/// The title of the menu field.
let title: String
// MARK: View
public var body: some View {
VStack(spacing: 0) {
Menu {
Picker(selection: $selection) {
ForEach(options, id: \.hashValue) { option in
Text(option.localizedName).tag(option)
}
} label: {
Text("")
}
} label: {
HStack {
Text(title)
.multilineTextAlignment(.leading)
.foregroundColor(
(isEnabled
? SharedAsset.Colors.textPrimary
: SharedAsset.Colors.textSecondary
).swiftUIColor,
)
.padding(.vertical, 19)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Text(selection.localizedName)
.accessibilityIdentifier(selectionAccessibilityID ?? "")
.multilineTextAlignment(.trailing)
.foregroundColor(SharedAsset.Colors.textSecondary.swiftUIColor)
.accessibilityIdentifier(selectionAccessibilityID ?? "")
}
}
.styleGuide(.body)
.accessibilityIdentifier(accessibilityIdentifier ?? "")
.id(title)
.padding(.horizontal, 16)
if hasDivider {
Divider()
.padding(.leading, 16)
}
}
.background(
isEnabled
? SharedAsset.Colors.backgroundSecondary.swiftUIColor
: SharedAsset.Colors.backgroundSecondaryDisabled.swiftUIColor,
)
}
/// Initializes a new `SettingsMenuField`.
///
/// - Parameters:
/// - title: The title of the menu field.
/// - options: The options that the user can choose between.
/// - hasDivider: Whether the menu field should have a bottom divider.
/// - accessibilityIdentifier: The accessibility ID for the menu field.
/// - selectionAccessibilityID: The accessibility ID for the picker selection.
/// - selection: A `Binding` for the currently selected option.
///
public init(
title: String,
options: [T],
hasDivider: Bool = true,
accessibilityIdentifier: String? = nil,
selectionAccessibilityID: String? = nil,
selection: Binding<T>,
) {
self.accessibilityIdentifier = accessibilityIdentifier
self.hasDivider = hasDivider
self.options = options
_selection = selection
self.selectionAccessibilityID = selectionAccessibilityID
self.title = title
}
}
// MARK: Previews
#if DEBUG
private enum MenuPreviewOptions: CaseIterable, Menuable {
case bear, bird, dog
var localizedName: String {
switch self {
case .bear: "🧸"
case .bird: "🪿"
case .dog: "🐕"
}
}
}
#Preview {
Group {
VStack(spacing: 0) {
SettingsMenuField(
title: "Bear",
options: MenuPreviewOptions.allCases,
selection: .constant(.bear),
)
SettingsMenuField(
title: "Dog",
options: MenuPreviewOptions.allCases,
hasDivider: false,
selection: .constant(.dog),
)
.disabled(true)
}
.padding(8)
}
.background(Color(.systemGroupedBackground))
}
#endif

View File

@ -117,17 +117,6 @@ public struct LoadingViewType: BaseViewType {
]
}
/// A generic type wrapper around `SettingsMenuField` to allow `ViewInspector` to find instances of
/// `SettingsMenuField` without needing to know the details of its implementation.
///
public struct SettingsMenuFieldType: BaseViewType {
public static var typePrefix: String = "SettingsMenuField"
public static var namespacedPrefixes: [String] = [
"BitwardenKit.SettingsMenuField",
]
}
// MARK: InspectableView
public extension InspectableView {
@ -300,21 +289,6 @@ public extension InspectableView {
try find(ViewType.SecureField.self, containing: label)
}
/// Attempts to locate a settings menu field with the provided title.
///
/// - Parameters:
/// - title: The title to use while searching for a menu field.
/// - locale: The locale for text extraction.
/// - Returns: A `SettingsMenuField`, if one can be located.
/// - Throws: Throws an error if a view was unable to be located.
///
func find(
settingsMenuField title: String,
locale: Locale = .testsDefault,
) throws -> InspectableView<SettingsMenuFieldType> {
try find(SettingsMenuFieldType.self, containing: title, locale: locale)
}
/// Attempts to locate a slider with the provided accessibility label.
///
/// - Parameter accessibilityLabel: The accessibility label to use while searching for a slider.
@ -478,15 +452,6 @@ public extension InspectableView where View == BitwardenMenuFieldType {
}
}
public extension InspectableView where View == SettingsMenuFieldType {
/// Selects a new value in the menu field.
///
func select(newValue: any Hashable) throws {
let picker = try find(ViewType.Picker.self)
try picker.select(value: newValue)
}
}
public extension InspectableView where View == BitwardenStepperType {
/// Decrements the stepper.
///