From 4f6f2e85ea3e7518895f22defe6c6ebbf9969f4b Mon Sep 17 00:00:00 2001 From: Katherine Bertelsen Date: Fri, 12 Apr 2024 16:34:18 -0500 Subject: [PATCH] Build edit item screen (#23) --- .../TOTP/TOTPCryptoHashAlgorithm.swift | 6 +- .../Core/Vault/Services/TOTP/TOTPKey.swift | 15 ++ .../Vault/Services/TOTP/TOTPKeyModel.swift | 17 +- .../Icons/down-angle.imageset/Contents.json | 16 ++ .../Icons/down-angle.imageset/down-angle.pdf | Bin 0 -> 2963 bytes .../Icons/up-angle.imageset/Contents.json | 16 ++ .../Icons/up-angle.imageset/up-angle.pdf | Bin 0 -> 2960 bytes .../en.lproj/Localizable.strings | 3 + .../Views/FormFields/FormMenuFieldView.swift | 124 +++++++++++++ .../Views/FormFields/FormTextFieldView.swift | 175 ++++++++++++++++++ .../Views/FormFields/SliderFieldView.swift | 108 +++++++++++ .../Views/FormFields/StepperFieldView.swift | 84 +++++++++ .../Views/FormFields/ToggleFieldView.swift | 79 ++++++++ .../Application/Views/SettingsMenuField.swift | 133 +++++++++++++ .../Views/SettingsMenuFieldTests.swift | 60 ++++++ .../AuthenticatorItemState.swift | 75 +++++++- .../EditAuthenticatorItemAction.swift | 27 ++- .../EditAuthenticatorItemProcessor.swift | 38 +++- .../EditAuthenticatorItemState.swift | 22 ++- .../EditAuthenticatorItemView.swift | 85 ++++++++- 20 files changed, 1049 insertions(+), 34 deletions(-) create mode 100644 AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/Contents.json create mode 100644 AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/down-angle.pdf create mode 100644 AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/up-angle.imageset/Contents.json create mode 100644 AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/up-angle.imageset/up-angle.pdf create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormMenuFieldView.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormTextFieldView.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/FormFields/SliderFieldView.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/FormFields/StepperFieldView.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/FormFields/ToggleFieldView.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuField.swift create mode 100644 AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuFieldTests.swift diff --git a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPCryptoHashAlgorithm.swift b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPCryptoHashAlgorithm.swift index 060f2b59..8ba3ec2a 100644 --- a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPCryptoHashAlgorithm.swift +++ b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPCryptoHashAlgorithm.swift @@ -1,6 +1,6 @@ /// Defines the hash algorithms supported for TOTP. /// -enum TOTPCryptoHashAlgorithm: String { +enum TOTPCryptoHashAlgorithm: String, Menuable, CaseIterable { case sha1 = "SHA1" case sha256 = "SHA256" case sha512 = "SHA512" @@ -18,4 +18,8 @@ enum TOTPCryptoHashAlgorithm: String { self = .sha1 } } + + var localizedName: String { + rawValue + } } diff --git a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKey.swift b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKey.swift index b3cd6ec1..f288bc0a 100644 --- a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKey.swift +++ b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKey.swift @@ -12,6 +12,13 @@ enum TOTPKey: Equatable { // MARK: Properties + /// The account name for the TOTP code. + /// Only works for `otpAuthUri` types. + var accountName: String? { + guard case let .otpAuthUri(model) = self else { return nil } + return model.accountName + } + /// The hash algorithm used for the TOTP code. /// For `otpAuthUri`, it extracts the algorithm from the model. /// Defaults to SHA1 for other types. @@ -35,6 +42,14 @@ enum TOTPKey: Equatable { } } + /// The issuer for the TOTP code. + /// Only works for `otpAuthUri` types. + var issuer: String? { + guard case let .otpAuthUri(model) = self else { return nil } + return model.issuer + } + + /// The key used for generating the TOTP code. /// Directly returns the key for base32 and Steam URI. /// For `otpAuthUri`, extracts the key from the model. diff --git a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKeyModel.swift b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKeyModel.swift index 21e54fc0..caf6d267 100644 --- a/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKeyModel.swift +++ b/AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKeyModel.swift @@ -3,6 +3,9 @@ public struct TOTPKeyModel: Equatable, Sendable { // MARK: Properties + /// The account name of the TOTP code. + let accountName: String? + /// The hash algorithm used for the TOTP code. /// let algorithm: TOTPCryptoHashAlgorithm @@ -16,6 +19,9 @@ public struct TOTPKeyModel: Equatable, Sendable { /// let digits: Int + /// The issuer of the TOTP code. + let issuer: String? + /// The time period (in seconds) for which the TOTP code is valid. /// let period: Int @@ -31,12 +37,15 @@ public struct TOTPKeyModel: Equatable, Sendable { /// Initializes a new configuration from an authenticator key. /// /// - Parameter authenticatorKey: A string representing the TOTP key. - init?(authenticatorKey: String) { - guard let keyType = TOTPKey(authenticatorKey) else { return nil } + init?(authenticatorKey: String?) { + guard let authenticatorKey = authenticatorKey, + let keyType = TOTPKey(authenticatorKey) else { return nil } rawAuthenticatorKey = authenticatorKey totpKey = keyType - period = keyType.period - digits = keyType.digits + accountName = keyType.accountName algorithm = keyType.algorithm + digits = keyType.digits + issuer = keyType.issuer + period = keyType.period } } diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/Contents.json b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/Contents.json new file mode 100644 index 00000000..0b6be7a9 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "down-angle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/down-angle.pdf b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/down-angle.imageset/down-angle.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e0d1237d672c7b1b6a31f73abfc1dbc28a945fe9 GIT binary patch literal 2963 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 literal 0 HcmV?d00001 diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 0815b7a0..079f21b7 100644 --- a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -436,6 +436,8 @@ "TenSeconds" = "10 seconds"; "ThirtySeconds" = "30 seconds"; "TwentySeconds" = "20 seconds"; +"SixtySeconds" = "60 seconds"; +"NinetySeconds" = "90 seconds"; "TwoMinutes" = "2 minutes"; "ClearClipboard" = "Clear clipboard"; "ClearClipboardDescription" = "Automatically clear copied values from your clipboard."; @@ -880,3 +882,4 @@ "Duo" = "Duo"; "DuoUnsupported" = "Duo not yet supported."; "Issuer" = "Issuer"; +"Advanced" = "Advanced"; diff --git a/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormMenuFieldView.swift b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormMenuFieldView.swift new file mode 100644 index 00000000..eb3fe0f5 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormMenuFieldView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +// MARK: - FormMenuField + +/// The data necessary for displaying a `FormMenuFieldView`. +/// +struct FormMenuField: Equatable, Identifiable { + // MARK: Properties + + /// The accessibility identifier to apply to the field. + let accessibilityIdentifier: String? + + /// The footer text displayed below the menu field. + let footer: String? + + /// A key path for updating the backing value for the menu field. + let keyPath: WritableKeyPath + + /// The options displayed in the menu. + let options: [T] + + /// The current selection. + let selection: T + + /// The title of the field. + let title: String + + // MARK: Identifiable + + var id: String { + "FormMenuField-\(title)" + } + + // MARK: Initialization + + /// Initialize a `FormMenuField`. + /// + /// - Parameters: + /// - accessibilityIdentifier: The accessibility identifier given to the menu field. + /// - footer: The footer text displayed below the menu field. + /// - keyPath: A key path for updating the backing value for the menu field. + /// - options: The options displayed in the menu. + /// - selection: The current selection. + /// - title: The title of the field. + init( + accessibilityIdentifier: String?, + footer: String? = nil, + keyPath: WritableKeyPath, + options: [T], + selection: T, + title: String + ) { + self.accessibilityIdentifier = accessibilityIdentifier + self.footer = footer + self.keyPath = keyPath + self.options = options + self.selection = selection + self.title = title + } +} + +// MARK: - FormMenuFieldView + +/// A view that displays a menu field for display in a form. +/// +struct FormMenuFieldView: View { + // MARK: Properties + + /// A closure containing the action to take when the menu selection is changed. + let action: (T) -> Void + + /// The data for displaying the field. + let field: FormMenuField + + /// Optional content view that is displayed to the right of the menu value. + let trailingContent: TrailingContent + + // MARK: View + + var body: some View { + BitwardenMenuField( + title: field.title, + footer: field.footer, + accessibilityIdentifier: field.accessibilityIdentifier, + options: field.options, + selection: Binding(get: { field.selection }, set: action), + trailingContent: { trailingContent } + ) + } + + // MARK: Initialization + + /// Initialize a `FormMenuFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - action: A closure containing the action to take when the menu selection is changed. + /// + init( + field: FormMenuField, + action: @escaping (T) -> Void + ) where TrailingContent == EmptyView { + self.action = action + self.field = field + trailingContent = EmptyView() + } + + /// Initialize a `FormMenuFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - action: A closure containing the action to take when the menu selection is changed. + /// - trailingContent: Optional content view that is displayed to the right of the menu value. + /// + init( + field: FormMenuField, + action: @escaping (T) -> Void, + trailingContent: @escaping () -> TrailingContent + ) { + self.action = action + self.field = field + self.trailingContent = trailingContent() + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormTextFieldView.swift b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormTextFieldView.swift new file mode 100644 index 00000000..efdeb950 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/FormTextFieldView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +// MARK: - FormTextField + +/// The data necessary for displaying a `FormTextFieldView`. +/// +struct FormTextField: Equatable, Identifiable { + // MARK: Types + + /// An enum describing the behavior for when the input should be automatically capitalized. + /// + enum Autocapitalization { + /// Input is never capitalized. + case never + + /// The first letter of a sentence should be capitalized. + case sentences + + /// The first letter of every word should be capitalized. + case words + + /// Returns the `TextInputAutocapitalization` behavior. + var textInputAutocapitalization: TextInputAutocapitalization { + switch self { + case .never: + return .never + case .sentences: + return .sentences + case .words: + return .words + } + } + } + + // MARK: Properties + + /// The accessibility id for the text field. The `title` will be used as the accessibility id + /// if this is `nil`. + let accessibilityId: String? + + /// The behavior for when the input should be automatically capitalized. + let autocapitalization: Autocapitalization + + /// Whether autocorrect is disabled in the text field. + let isAutocorrectDisabled: Bool + + /// Whether a password displayed in the text field is visible. + let isPasswordVisible: Bool? + + /// A key path for updating whether a password displayed in the text field is visible. + let isPasswordVisibleKeyPath: WritableKeyPath? + + /// The type of keyboard to display. + let keyboardType: UIKeyboardType + + /// A key path for updating the backing value for the text field. + let keyPath: WritableKeyPath + + /// The accessibility id for the button to toggle password visibility. + let passwordVisibilityAccessibilityId: String? + + /// The expected type of content input in the text field. + let textContentType: UITextContentType? + + /// The title of the field. + let title: String + + /// The current text value. + let value: String + + // MARK: Identifiable + + var id: String { + "FormTextField-\(title)" + } + + // MARK: Initialization + + /// Initialize a `FormTextField`. + /// + /// - Parameters: + /// - accessibilityId: The accessibility id for the text field. + /// - autocapitalization: The behavior for when the input should be automatically capitalized. + /// Defaults to `.sentences`. + /// - isAutocorrectDisabled: Whether autocorrect is disabled in the text field. Defaults to + /// `false`. + /// - isPasswordVisible: Whether a password displayed in the text field is visible + /// - isPasswordVisibleKeyPath: A key path for updating whether a password displayed in the + /// text field is visible. + /// - keyboardType: The type of keyboard to display. + /// - keyPath: A key path for updating the backing value for the text field. + /// - passwordVisibilityAccessibilityId: The accessibility id for the password visibility button. + /// - textContentType: The expected type of content input in the text field. Defaults to `nil`. + /// - title: The title of the field. + /// - value: The current text value. + init( + accessibilityId: String? = nil, + autocapitalization: Autocapitalization = .sentences, + isAutocorrectDisabled: Bool = false, + isPasswordVisible: Bool? = nil, + isPasswordVisibleKeyPath: WritableKeyPath? = nil, + keyboardType: UIKeyboardType = .default, + keyPath: WritableKeyPath, + passwordVisibilityAccessibilityId: String? = nil, + textContentType: UITextContentType? = nil, + title: String, + value: String + ) { + self.accessibilityId = accessibilityId + self.autocapitalization = autocapitalization + self.isAutocorrectDisabled = isAutocorrectDisabled + self.isPasswordVisible = isPasswordVisible + self.isPasswordVisibleKeyPath = isPasswordVisibleKeyPath + self.keyboardType = keyboardType + self.keyPath = keyPath + self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId + self.textContentType = textContentType + self.title = title + self.value = value + } +} + +// MARK: - FormTextFieldView + +/// A view that displays a text field for display in a form. +/// +struct FormTextFieldView: View { + // MARK: Properties + + /// A closure containing the action to take when the text is changed. + let action: (String) -> Void + + /// The data for displaying the field. + let field: FormTextField + + /// A closure containing the action to take when the value for whether a password is displayed + /// in the text field is changed. + let isPasswordVisibleChangedAction: ((Bool) -> Void)? + + var body: some View { + BitwardenTextField( + title: field.title, + text: Binding(get: { field.value }, set: action), + accessibilityIdentifier: field.accessibilityId ?? field.title, + passwordVisibilityAccessibilityId: field.passwordVisibilityAccessibilityId, + isPasswordVisible: field.isPasswordVisible.map { isPasswordVisible in + Binding(get: { isPasswordVisible }, set: isPasswordVisibleChangedAction ?? { _ in }) + } + ) + .autocorrectionDisabled(field.isAutocorrectDisabled) + .keyboardType(field.keyboardType) + .textContentType(field.textContentType) + .textInputAutocapitalization(field.autocapitalization.textInputAutocapitalization) + } + + // MARK: Initialization + + /// Initialize a `FormTextFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - action: A closure containing the action to take when the text is changed. + /// - isPasswordVisibleChangedAction: A closure containing the action to take when the value + /// for whether a password is displayed in the text field is changed. + /// + init( + field: FormTextField, + action: @escaping (String) -> Void, + isPasswordVisibleChangedAction: ((Bool) -> Void)? = nil + ) { + self.action = action + self.field = field + self.isPasswordVisibleChangedAction = isPasswordVisibleChangedAction + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Views/FormFields/SliderFieldView.swift b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/SliderFieldView.swift new file mode 100644 index 00000000..b71d9bce --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/SliderFieldView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +// MARK: - SliderField + +/// The data necessary for displaying a `SliderFieldView`. +/// +struct SliderField: Equatable, Identifiable { + // MARK: Properties + + /// A key path for updating the backing value for the slider field. + let keyPath: WritableKeyPath + + /// The range of allowable values for the slider. + let range: ClosedRange + + /// The accessibility id for the slider. The `title` will be used as the accessibility id + /// if this is `nil`. + let sliderAccessibilityId: String? + + /// The accessibility id for the slider value. The `id` will be used as the accessibility id + /// if this is `nil`. + let sliderValueAccessibilityId: String? + + /// The distance between each valid value. + let step: Double + + /// The title of the field. + let title: String + + /// The current slider value. + let value: Double + + // MARK: Identifiable + + var id: String { + "SliderField-\(title)" + } +} + +// MARK: - SliderFieldView + +/// A view that displays a slider for display in a form. +/// +struct SliderFieldView: View { + // MARK: Properties + + /// The data for displaying the field. + let field: SliderField + + /// A closure containing the action to take when the slider begins or ends editing. + let onEditingChanged: (Bool) -> Void + + /// A closure containing the action to take when a new value is selected. + let onValueChanged: (Double) -> Void + + var body: some View { + VStack(spacing: 8) { + HStack { + Text(field.title) + .styleGuide(.body) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + + Spacer() + + Text(String(Int(field.value))) + .styleGuide(.body, monoSpacedDigit: true) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + .accessibilityIdentifier(field.sliderValueAccessibilityId ?? field.id) + } + .accessibilityHidden(true) + + Divider() + + Slider( + value: Binding(get: { field.value }, set: onValueChanged), + in: field.range, + step: field.step, + onEditingChanged: onEditingChanged + ) + .tint(Asset.Colors.primaryBitwarden.swiftUIColor) + .accessibilityLabel(field.title) + .accessibilityIdentifier(field.sliderAccessibilityId ?? field.title) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Asset.Colors.backgroundPrimary.swiftUIColor) + .cornerRadius(10) + } + + // MARK: Initialization + + /// Initialize a `SliderFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - onEditingChanged: A closure containing the action to take when the slider begins or ends editing. + /// - onValueChanged: A closure containing the action to take when a new value is selected. + /// + init( + field: SliderField, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + onValueChanged: @escaping (Double) -> Void + ) { + self.field = field + self.onEditingChanged = onEditingChanged + self.onValueChanged = onValueChanged + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Views/FormFields/StepperFieldView.swift b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/StepperFieldView.swift new file mode 100644 index 00000000..a19f808a --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/StepperFieldView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +// MARK: - StepperField + +/// The data necessary for displaying a `StepperFieldView`. +/// +struct StepperField: Equatable, Identifiable { + // MARK: Properties + + /// The accessibility id for the stepper. The `id` will be used as the accessibility id + /// if this is `nil`. + let accessibilityId: String? + + /// A key path for updating the backing value for the stepper field. + let keyPath: WritableKeyPath + + /// The range of allowable values for the stepper. + let range: ClosedRange + + /// The title of the field. + let title: String + + /// The current stepper value. + let value: Int + + // MARK: Identifiable + + var id: String { + "StepperField-\(title)" + } +} + +// MARK: - StepperFieldView + +/// A view that displays a stepper for display in a form. +/// +struct StepperFieldView: View { + // MARK: Properties + + /// A closure containing the action to take when a new value is selected. + let action: (Int) -> Void + + /// The data for displaying the field. + let field: StepperField + + var body: some View { + VStack(spacing: 16) { + Stepper( + value: Binding(get: { field.value }, set: action), + in: field.range + ) { + HStack { + Text(field.title) + .styleGuide(.body) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + + Spacer() + + Text(String(field.value)) + .styleGuide(.body, monoSpacedDigit: true) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + } + .padding(.trailing, 4) + } + .padding(.top, 4) + .accessibilityIdentifier(field.accessibilityId ?? field.id) + + Divider() + } + } + + // MARK: Initialization + + /// Initialize a `StepperFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - action: A closure containing the action to take when a new value is selected. + /// + init(field: StepperField, action: @escaping (Int) -> Void) { + self.action = action + self.field = field + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Views/FormFields/ToggleFieldView.swift b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/ToggleFieldView.swift new file mode 100644 index 00000000..bc224a9a --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/FormFields/ToggleFieldView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - ToggleField + +/// The data necessary for displaying a `ToggleFieldView`. +/// +struct ToggleField: Equatable, Identifiable { + // MARK: Properties + + /// The accessibility id for the toggle. The `id` will be used as the accessibility id + /// if this is `nil`. + let accessibilityId: String? + + /// The accessibility label for the toggle. The title will be used as the accessibility label + /// if this is `nil`. + let accessibilityLabel: String? + + /// Whether the toggle is disabled. + let isDisabled: Bool + + /// The current toggle value. + let isOn: Bool + + /// A key path for updating the backing value for the toggle field. + let keyPath: WritableKeyPath + + /// The title of the field. + let title: String + + // MARK: Identifiable + + var id: String { + "ToggleField-\(title)" + } +} + +// MARK: - ToggleFieldView + +/// A view that displays a toggle for display in a form. +/// +struct ToggleFieldView: View { + // MARK: Properties + + /// A closure containing the action to take when the toggle is toggled. + let action: (Bool) -> Void + + /// The data for displaying the field. + let field: ToggleField + + var body: some View { + VStack(spacing: 0) { + Toggle( + field.title, + isOn: Binding(get: { field.isOn }, set: action) + ) + .accessibilityIdentifier(field.accessibilityId ?? field.id) + .accessibilityLabel(field.accessibilityLabel ?? field.title) + .disabled(field.isDisabled) + .toggleStyle(.bitwarden) + .padding(.bottom, 16) + .padding(.top, 4) + + Divider() + } + } + + // MARK: Initialization + + /// Initialize a `ToggleFieldView`. + /// + /// - Parameters: + /// - field: The data for displaying the field. + /// - action: A closure containing the action to take when the toggle is toggled. + /// + init(field: ToggleField, action: @escaping (Bool) -> Void) { + self.action = action + self.field = field + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuField.swift b/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuField.swift new file mode 100644 index 00000000..cb753e7c --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuField.swift @@ -0,0 +1,133 @@ +import SwiftUI + +// MARK: - SettingsMenuField + +/// A standard input field that allows the user to select between a predefined set of +/// options. +/// +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 + + /// 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 + + 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(Asset.Colors.textPrimary.swiftUIColor) + .padding(.vertical, 19) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + Text(selection.localizedName) + .accessibilityIdentifier(selectionAccessibilityID ?? "") + .multilineTextAlignment(.trailing) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + .accessibilityIdentifier(selectionAccessibilityID ?? "") + } + } + .styleGuide(.body) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + .id(title) + .padding(.horizontal, 16) + + if hasDivider { + Divider() + .padding(.leading, 16) + } + } + .background(Asset.Colors.backgroundPrimary.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. + /// + 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: return "🧸" + case .bird: return "🪿" + case .dog: return "🐕" + } + } +} + +#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) + ) + } + .padding(8) + } + .background(Color(.systemGroupedBackground)) +} +#endif diff --git a/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuFieldTests.swift b/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuFieldTests.swift new file mode 100644 index 00000000..22bd13ff --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/SettingsMenuFieldTests.swift @@ -0,0 +1,60 @@ +import SwiftUI +import ViewInspector +import XCTest + +@testable import AuthenticatorShared + +class SettingsMenuFieldTests: AuthenticatorTestCase { + // 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/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemState.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemState.swift index 521263ab..f07552f2 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemState.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemState.swift @@ -25,14 +25,23 @@ struct AuthenticatorItemState: Equatable { // MARK: Properties - /// The account of the item - var account: String + /// The account name of the item + var accountName: String + + /// The algorithm of the item + var algorithm: TOTPCryptoHashAlgorithm /// The Add or Existing Configuration. let configuration: Configuration - /// A flag indicating if the key field is visible - var isKeyVisible: Bool = false + /// The number of digits in the OTP + var digits: TotpDigitsOptions + + /// A flag indicating if the advanced section is expanded. + var isAdvancedExpanded: Bool = false + + /// A flag indicating if the secret field is visible + var isSecretVisible: Bool = false /// The issuer of the item var issuer: String @@ -40,6 +49,12 @@ struct AuthenticatorItemState: Equatable { /// The name of this item. var name: String + /// The period for the OTP + var period: TotpPeriodOptions + + /// The secret of the OTP + var secret: String + /// A toast for views var toast: Toast? @@ -51,19 +66,38 @@ struct AuthenticatorItemState: Equatable { init( configuration: Configuration, name: String, + accountName: String, + algorithm: TOTPCryptoHashAlgorithm, + digits: TotpDigitsOptions, + issuer: String, + period: TotpPeriodOptions, + secret: String, totpState: LoginTOTPState ) { self.configuration = configuration self.name = name self.totpState = totpState - account = "Fixme" - issuer = "Fixme" + self.accountName = accountName + self.algorithm = algorithm + self.issuer = issuer + self.digits = digits + self.period = period + self.secret = secret } init?(existing authenticatorItemView: AuthenticatorItemView) { + guard let keyModel = TOTPKeyModel(authenticatorKey: authenticatorItemView.totpKey) else { + return nil + } self.init( configuration: .existing(authenticatorItemView: authenticatorItemView), name: authenticatorItemView.name, + accountName: keyModel.accountName ?? "", + algorithm: keyModel.algorithm, + digits: TotpDigitsOptions(rawValue: keyModel.digits) ?? .six, + issuer: keyModel.issuer ?? "", + period: TotpPeriodOptions(rawValue: keyModel.period) ?? .thirty, + secret: keyModel.base32Key, totpState: LoginTOTPState(authenticatorItemView.totpKey) ) } @@ -87,3 +121,32 @@ extension AuthenticatorItemState { ) } } + +enum TotpDigitsOptions: Int, Menuable, CaseIterable { + case six = 6 + case eight = 8 + case ten = 10 + case twelve = 12 + + var localizedName: String { + "\(rawValue)" + } +} + +enum TotpPeriodOptions: Int, Menuable, CaseIterable { + case thirty = 30 + case sixty = 60 + case ninety = 90 + + var localizedName: String { + switch self { + case .thirty: + Localizations.thirtySeconds + case .sixty: + Localizations.sixtySeconds + case .ninety: + Localizations.ninetySeconds + } + } +} + diff --git a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemAction.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemAction.swift index fb9e0ffc..1e1c67a7 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemAction.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemAction.swift @@ -2,21 +2,36 @@ import BitwardenSdk /// Synchronous actions that can be processed by an `EditItemProcessor`. enum EditAuthenticatorItemAction: Equatable { - /// The account field was changed. - case accountChanged(String) + /// The account name field was changed. + case accountNameChanged(String) + + /// The advanced button was pressed. + case advancedPressed + + /// The algorithm field was changed. + case algorithmChanged(TOTPCryptoHashAlgorithm) + + /// The digits field was changed. + case digitsChanged(TotpDigitsOptions) /// The dismiss button was pressed. case dismissPressed - /// The key field was changed. - case keyChanged(String) + /// The issuer field was changed + case issuerChanged(String) /// The item's name was changed case nameChanged(String) + /// The item's period was changed + case periodChanged(TotpPeriodOptions) + + /// The secret field was changed. + case secretChanged(String) + /// The toast was shown or hidden. case toastShown(Toast?) - /// The toggle key visibility button was changed. - case toggleKeyVisibilityChanged(Bool) + /// The toggle secret visibility button was changed. + case toggleSecretVisibilityChanged(Bool) } diff --git a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemProcessor.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemProcessor.swift index 305cb751..34dd75a2 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemProcessor.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemProcessor.swift @@ -1,5 +1,5 @@ -import BitwardenSdk import Foundation +import OSLog /// The processor used to manage state and handle actions/effects for the edit item screen final class EditAuthenticatorItemProcessor: StateProcessor< @@ -53,16 +53,26 @@ final class EditAuthenticatorItemProcessor: StateProcessor< override func receive(_ action: EditAuthenticatorItemAction) { switch action { - case let .accountChanged(account): - state.account = account + case let .accountNameChanged(accountName): + state.accountName = accountName + case .advancedPressed: + state.isAdvancedExpanded.toggle() + case let .algorithmChanged(algorithm): + state.algorithm = algorithm + case let .digitsChanged(digits): + state.digits = digits case .dismissPressed: coordinator.navigate(to: .dismiss()) - case let .keyChanged(key): - state.totpState = LoginTOTPState(key) + case let .issuerChanged(issuer): + state.issuer = issuer case let .nameChanged(newValue): state.name = newValue - case let .toggleKeyVisibilityChanged(isVisible): - state.isKeyVisible = isVisible + case let .periodChanged(period): + state.period = period + case let .secretChanged(secret): + state.totpState = LoginTOTPState(secret) + case let .toggleSecretVisibilityChanged(isVisible): + state.isSecretVisible = isVisible case let .toastShown(toast): state.toast = toast } @@ -91,10 +101,20 @@ final class EditAuthenticatorItemProcessor: StateProcessor< case .add: return case let .existing(authenticatorItemView: authenticatorItemView): + guard let secret = state.totpState.authKeyModel?.base32Key else { return } + let newOtpUri = OTPAuthModel( + accountName: state.accountName.nilIfEmpty, + algorithm: state.algorithm, + digits: state.digits.rawValue, + issuer: state.issuer.nilIfEmpty, + period: state.period.rawValue, + secret: secret + ) + let newAuthenticatorItemView = AuthenticatorItemView( id: authenticatorItemView.id, - name: authenticatorItemView.name, - totpKey: state.totpState.rawAuthenticatorKeyString + name: state.name, + totpKey: newOtpUri.otpAuthUri ) try await updateAuthenticatorItem(authenticatorItem: newAuthenticatorItemView) } diff --git a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemState.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemState.swift index 49d2e4f8..438bd393 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemState.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemState.swift @@ -2,17 +2,27 @@ import BitwardenSdk import Foundation /// The state of an `EditAuthenticatorItemView` +/// and `EditAdvancedAuthenticatorItemView` protocol EditAuthenticatorItemState: Sendable { // MARK: Properties /// The account of the item - var account: String { get set } + var accountName: String { get set } + + /// The algorithm of the OTP item + var algorithm: TOTPCryptoHashAlgorithm { get set } /// The Add or Existing Configuration. var configuration: AuthenticatorItemState.Configuration { get } - /// A flag indicating if the key is visible. - var isKeyVisible: Bool { get set } + /// The number of digits in the OTP + var digits: TotpDigitsOptions { get set } + + /// A flag indicating if the advanced section is expanded. + var isAdvancedExpanded: Bool { get set } + + /// A flag indicating if the secret is visible. + var isSecretVisible: Bool { get set } /// The issuer of the item var issuer: String { get set } @@ -20,6 +30,12 @@ protocol EditAuthenticatorItemState: Sendable { /// The name of this item. var name: String { get set } + /// The secret of the OTP item + var secret: String { get set } + + /// The period of the OTP in seconds + var period: TotpPeriodOptions { get set } + /// A toast for views var toast: Toast? { get set } diff --git a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemView.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemView.swift index c1c5af8c..d0a3ef17 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemView.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/EditAuthenticatorItem/EditAuthenticatorItemView.swift @@ -30,8 +30,14 @@ struct EditAuthenticatorItemView: View { private var content: some View { ScrollView { - VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 20) { informationSection + advancedButton + + if store.state.isAdvancedExpanded { + advancedOptions + } + saveButton } .padding(16) @@ -57,16 +63,79 @@ struct EditAuthenticatorItemView: View { BitwardenTextField( title: Localizations.authenticatorKey, text: store.binding( - get: \.totpState.rawAuthenticatorKeyString!, - send: EditAuthenticatorItemAction.keyChanged + get: \.secret, + send: EditAuthenticatorItemAction.secretChanged ), isPasswordVisible: store.binding( - get: \.isKeyVisible, - send: EditAuthenticatorItemAction.toggleKeyVisibilityChanged + get: \.isSecretVisible, + send: EditAuthenticatorItemAction.toggleSecretVisibilityChanged ) ) .textFieldConfiguration(.password) + + BitwardenTextField( + title: "Account name", + text: store.binding( + get: \.accountName, + send: EditAuthenticatorItemAction.accountNameChanged + ) + ) + + BitwardenTextField( + title: Localizations.issuer, + text: store.binding( + get: \.issuer, + send: EditAuthenticatorItemAction.issuerChanged + ) + ) + } + } + + private var advancedButton: some View { + Button { + store.send(.advancedPressed) + } label: { + HStack(spacing: 8) { + Text(Localizations.advanced) + .styleGuide(.body) + + Asset.Images.downAngle.swiftUIImage + .imageStyle(.accessoryIcon) + .rotationEffect(store.state.isAdvancedExpanded ? Angle(degrees: 180) : .zero) + } + .padding(.vertical, 12) + .foregroundStyle(Asset.Colors.primaryBitwarden.swiftUIColor) } + .accessibilityIdentifier("EditShowHideAdvancedButton") + } + + @ViewBuilder private var advancedOptions: some View { + BitwardenMenuField( + title: "Algorithm", + options: TOTPCryptoHashAlgorithm.allCases, + selection: store.binding( + get: \.algorithm, + send: EditAuthenticatorItemAction.algorithmChanged + ) + ) + + BitwardenMenuField( + title: "Refresh period", + options: TotpPeriodOptions.allCases, + selection: store.binding( + get: \.period, + send: EditAuthenticatorItemAction.periodChanged + ) + ) + + BitwardenMenuField( + title: "Number of digits", + options: TotpDigitsOptions.allCases, + selection: store.binding( + get: \.digits, + send: EditAuthenticatorItemAction.digitsChanged + ) + ) } private var saveButton: some View { @@ -92,6 +161,12 @@ struct EditAuthenticatorItemView: View { ) ), name: "Example", + accountName: "Account", + algorithm: .sha1, + digits: .six, + issuer: "Issuer", + period: .thirty, + secret: "example", totpState: LoginTOTPState( authKeyModel: TOTPKeyModel(authenticatorKey: "example")!, codeModel: TOTPCodeModel(