mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
[PM-26063] Update Authenticator's settings view to latest designs (#2113)
This commit is contained in:
parent
f9ae28de1d
commit
73fc2d84b8
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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.
|
||||
///
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user