From d42d4adce000da6807a27cdeb50df3c2c6d1e7b9 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Thu, 6 Nov 2025 14:53:34 -0600 Subject: [PATCH 1/2] [PM-26063] Update Authenticator's settings view to latest designs --- .../SettingsView+ViewInspectorTests.swift | 4 +- .../Settings/Settings/SettingsView.swift | 201 +++++++++--------- .../Views/BitwardenMenuField.swift | 36 ++-- 3 files changed, 120 insertions(+), 121 deletions(-) diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift index a66582c798..0b1287f5c6 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift @@ -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) diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift index 5cc2ba9272..388c4fb0ff 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift @@ -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) - } + /// 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) } - .padding(.bottom, 16) - - copyrightNotice } - .cornerRadius(10) } - /// The application's default save option picker view + /// 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 { diff --git a/BitwardenKit/UI/Platform/Application/Views/BitwardenMenuField.swift b/BitwardenKit/UI/Platform/Application/Views/BitwardenMenuField.swift index b6c95baeaf..7dca9cf11e 100644 --- a/BitwardenKit/UI/Platform/Application/Views/BitwardenMenuField.swift +++ b/BitwardenKit/UI/Platform/Application/Views/BitwardenMenuField.swift @@ -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 From 7ae5fc7ca8cda42698d2abf5f38027b9cc5878af Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Thu, 6 Nov 2025 15:09:20 -0600 Subject: [PATCH 2/2] [PM-26063] Remove unused SettingsMenuField --- ...SettingsMenuField+ViewInspectorTests.swift | 60 ------- .../Application/Views/SettingsMenuField.swift | 147 ------------------ .../InspectableView.swift | 35 ----- 3 files changed, 242 deletions(-) delete mode 100644 BitwardenKit/UI/Platform/Application/Views/SettingsMenuField+ViewInspectorTests.swift delete mode 100644 BitwardenKit/UI/Platform/Application/Views/SettingsMenuField.swift diff --git a/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField+ViewInspectorTests.swift deleted file mode 100644 index 608e90b06b..0000000000 --- a/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField+ViewInspectorTests.swift +++ /dev/null @@ -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! - - // 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") - } -} diff --git a/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField.swift b/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField.swift deleted file mode 100644 index 3d44a1fa9a..0000000000 --- a/BitwardenKit/UI/Platform/Application/Views/SettingsMenuField.swift +++ /dev/null @@ -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: 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, - ) { - 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 diff --git a/ViewInspectorTestHelpers/InspectableView.swift b/ViewInspectorTestHelpers/InspectableView.swift index fd5ec5a1f4..e10a5bbcda 100644 --- a/ViewInspectorTestHelpers/InspectableView.swift +++ b/ViewInspectorTestHelpers/InspectableView.swift @@ -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 { - 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. ///