Create settings view (#25)

This commit is contained in:
Katherine Bertelsen 2024-04-13 15:55:52 -05:00 committed by GitHub
parent 72bd35ef8a
commit 75ce22e2fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 3604 additions and 53 deletions

View File

@ -0,0 +1,136 @@
import Foundation
// MARK: - LanguageOption
/// An enum listing all the language options, either default (system settings) or any of the currently available
/// localizable files.
public enum LanguageOption: Equatable {
/// Use the system settings.
case `default`
/// Specify the language using the language code.
case custom(languageCode: String)
// MARK: Type Properties
/// All the language options.
static let allCases: [LanguageOption] = [.default] + languageCodes.map { .custom(languageCode: $0) }
/// Ideally we could dynamically fetch all the language codes available as localizable files
/// by calling `Bundle.main.localizations`, but since the Bundle currently doesn't
/// return reliable results for some reason, we have to hard-code the languages for now.
private static let languageCodes =
[
"af",
"be",
"bg",
"ca",
"cs",
"da",
"de",
"el",
"en",
"en-GB",
"eo",
"es",
"et",
"fa",
"fi",
"fr",
"he",
"hi",
"hr",
"hu",
"id",
"it",
"ja",
"ko",
"lv",
"ml",
"nb",
"nl",
"pl",
"pt-BT",
"pt-PT",
"ro",
"ru",
"sk",
"sv",
"th",
"tr",
"uk",
"vi",
"zh-CN",
"zh-TW",
]
// MARK: Properties
/// Whether the language option is the last one in the list.
var isLast: Bool {
switch self {
case .default:
false
case let .custom(languageCode: languageCode):
languageCode == LanguageOption.allCases.last?.value
}
}
/// The title of the language option as it appears in the list of options.
var title: String {
switch self {
case .default:
Localizations.defaultSystem
case let .custom(languageCode: languageCode):
// Create a Locale using the language code in order to extract
// its full reader-friendly name.
Locale(identifier: languageCode)
.localizedString(forIdentifier: languageCode)?
.localizedCapitalized ??
Locale.current
.localizedString(forIdentifier: languageCode)?
.localizedCapitalized ?? ""
}
}
/// The two letter language code representation of the language, or `nil` for the system default.
var value: String? {
switch self {
case .default:
nil
case let .custom(languageCode: languageCode):
languageCode
}
}
// MARK: Initialization
/// Initialize a `LanguageOption`.`
///
/// - Parameter languageCode: The language code of the custom selection, or `nil` for default.
///
init(_ languageCode: String?) {
if let languageCode {
self = .custom(languageCode: languageCode)
} else {
self = .default
}
}
}
// MARK: - Identifiable
extension LanguageOption: Identifiable {
public var id: String {
switch self {
case .default:
"default"
case let .custom(languageCode: languageCode):
languageCode
}
}
}
// MARK: - Hashable
extension LanguageOption: Hashable {}

View File

@ -0,0 +1,33 @@
import XCTest
@testable import AuthenticatorShared
class LanguageOptionTests: AuthenticatorTestCase {
// MARK: Tests
/// `allCases` returns the expected result.
func test_allCases() {
let allCases = LanguageOption.allCases
XCTAssertEqual(allCases.first, .default)
XCTAssertEqual(allCases[1], .custom(languageCode: "af"))
XCTAssertEqual(allCases.count, 42)
}
/// `init` returns the correct values.
func test_init() {
XCTAssertEqual(LanguageOption(nil), .default)
XCTAssertEqual(LanguageOption("de"), .custom(languageCode: "de"))
}
/// `title` returns the correct string.
func test_title() {
XCTAssertEqual(LanguageOption.default.title, Localizations.defaultSystem)
XCTAssertEqual(LanguageOption.custom(languageCode: "de").title, "Deutsch")
}
/// `value` returns the correct string.
func test_value() {
XCTAssertNil(LanguageOption.default.value)
XCTAssertEqual(LanguageOption.custom(languageCode: "de").value, "de")
}
}

View File

@ -18,6 +18,9 @@ public class ServiceContainer: Services {
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
let application: Application?
/// The service for persisting app setting values.
let appSettingsStore: AppSettingsStore
/// The service used for managing items
let authenticatorItemRepository: AuthenticatorItemRepository
@ -36,6 +39,9 @@ public class ServiceContainer: Services {
/// The service used by the application for sharing data with other apps.
let pasteboardService: PasteboardService
/// The service used by the application to manage account state.
let stateService: StateService
/// Provides the present time for TOTP Code Calculation.
let timeProvider: TimeProvider
@ -48,27 +54,32 @@ public class ServiceContainer: Services {
///
/// - Parameters:
/// - application: The application instance.
/// - appSettingsStore: The service for persisting app settings
/// - authenticatorItemRepository: The service to manage items
/// - cameraService: The service used by the application to manage camera use.
/// - clientService: The service used by the application to handle encryption and decryption tasks.
/// - cryptographyService: The service used by the application to encrypt and decrypt items
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - pasteboardService: The service used by the application for sharing data with other apps.
/// - stateService: The service for managing account state.
/// - timeProvider: Provides the present time for TOTP Code Calculation.
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
///
init(
application: Application?,
appSettingsStore: AppSettingsStore,
authenticatorItemRepository: AuthenticatorItemRepository,
cameraService: CameraService,
cryptographyService: CryptographyService,
clientService: ClientService,
errorReporter: ErrorReporter,
pasteboardService: PasteboardService,
stateService: StateService,
timeProvider: TimeProvider,
totpService: TOTPService
) {
self.application = application
self.appSettingsStore = appSettingsStore
self.authenticatorItemRepository = authenticatorItemRepository
self.cameraService = cameraService
self.clientService = clientService
@ -76,6 +87,7 @@ public class ServiceContainer: Services {
self.errorReporter = errorReporter
self.pasteboardService = pasteboardService
self.timeProvider = timeProvider
self.stateService = stateService
self.totpService = totpService
}
@ -102,6 +114,7 @@ public class ServiceContainer: Services {
appIdService: appIdService,
keychainService: keychainService
)
let stateService = DefaultStateService(appSettingsStore: appSettingsStore, dataStore: dataStore)
let timeProvider = CurrentTime()
let cryptographyKeyService = CryptographyKeyService(
@ -131,12 +144,14 @@ public class ServiceContainer: Services {
self.init(
application: application,
appSettingsStore: appSettingsStore,
authenticatorItemRepository: authenticatorItemRepository,
cameraService: cameraService,
cryptographyService: cryptographyService,
clientService: clientService,
errorReporter: errorReporter,
pasteboardService: pasteboardService,
stateService: stateService,
timeProvider: timeProvider,
totpService: totpService
)

View File

@ -6,6 +6,7 @@ typealias Services = HasAuthenticatorItemRepository
& HasCryptographyService
& HasErrorReporter
& HasPasteboardService
& HasStateService
& HasTOTPService
& HasTimeProvider
@ -44,6 +45,13 @@ protocol HasPasteboardService {
var pasteboardService: PasteboardService { get }
}
/// Protocol for an object that provides a `StateService`.
///
protocol HasStateService {
/// The service used by the application to manage account state.
var stateService: StateService { get }
}
/// Protocol for an object that provides a `TOTPService`.
///
protocol HasTOTPService {

View File

@ -0,0 +1,162 @@
import Combine
import Foundation
// MARK: - StateService
/// A protocol for a `StateService` which manages the saved state of the app
///
protocol StateService: AnyObject {
/// The language option currently selected for the app.
var appLanguage: LanguageOption { get set }
/// Get the app theme.
///
/// - Returns: The app theme.
///
func getAppTheme() async -> AppTheme
/// Gets the clear clipboard value for an account.
///
/// - Parameter userId: The user ID associated with the clear clipboard value. Defaults to the active
/// account if `nil`
/// - Returns: The time after which the clipboard should clear.
///
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue
/// Get whether to show website icons.
///
/// - Returns: Whether to show the website icons.
///
func getShowWebIcons() async -> Bool
/// Sets the app theme.
///
/// - Parameter appTheme: The new app theme.
///
func setAppTheme(_ appTheme: AppTheme) async
/// Sets the clear clipboard value for an account.
///
/// - Parameters:
/// - clearClipboardValue: The time after which to clear the clipboard.
/// - userId: The user ID of the account. Defaults to the active account if `nil`.
///
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws
/// Set whether to show the website icons.
///
/// - Parameter showWebIcons: Whether to show the website icons.
///
func setShowWebIcons(_ showWebIcons: Bool) async
// MARK: Publishers
/// A publisher for the app theme.
///
/// - Returns: A publisher for the app theme.
///
func appThemePublisher() async -> AnyPublisher<AppTheme, Never>
/// A publisher for whether or not to show the web icons.
///
/// - Returns: A publisher for whether or not to show the web icons.
///
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never>
}
// MARK: - DefaultStateService
/// A default implementation of `StateService`.
///
actor DefaultStateService: StateService {
// MARK: Properties
/// The language option currently selected for the app.
nonisolated var appLanguage: LanguageOption {
get { LanguageOption(appSettingsStore.appLocale) }
set { appSettingsStore.appLocale = newValue.value }
}
// MARK: Private Properties
/// The service that persists app settings.
let appSettingsStore: AppSettingsStore
/// A subject containing the app theme.
private var appThemeSubject: CurrentValueSubject<AppTheme, Never>
/// The data store that handles performing data requests.
private let dataStore: DataStore
/// A subject containing whether to show the website icons.
private var showWebIconsSubject: CurrentValueSubject<Bool, Never>
// MARK: Initialization
/// Initialize a `DefaultStateService`.
///
/// - Parameters:
/// - appSettingsStore: The service that persists app settings.
/// - dataStore: The data store that handles performing data requests.
///
init(
appSettingsStore: AppSettingsStore,
dataStore: DataStore
) {
self.appSettingsStore = appSettingsStore
self.dataStore = dataStore
appThemeSubject = CurrentValueSubject(AppTheme(appSettingsStore.appTheme))
showWebIconsSubject = CurrentValueSubject(!appSettingsStore.disableWebIcons)
}
// MARK: Methods
func getAppTheme() async -> AppTheme {
AppTheme(appSettingsStore.appTheme)
}
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.clearClipboardValue(userId: userId)
}
func getShowWebIcons() async -> Bool {
!appSettingsStore.disableWebIcons
}
func setAppTheme(_ appTheme: AppTheme) async {
appSettingsStore.appTheme = appTheme.value
appThemeSubject.send(appTheme)
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setClearClipboardValue(clearClipboardValue, userId: userId)
}
func setShowWebIcons(_ showWebIcons: Bool) async {
appSettingsStore.disableWebIcons = !showWebIcons
showWebIconsSubject.send(showWebIcons)
}
// MARK: Publishers
func appThemePublisher() async -> AnyPublisher<AppTheme, Never> {
appThemeSubject.eraseToAnyPublisher()
}
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
showWebIconsSubject.eraseToAnyPublisher()
}
// MARK: Private
/// Returns the user ID for the active account.
///
/// - Returns: The user ID for the active account.
///
private func getActiveAccountUserId() throws -> String {
appSettingsStore.localUserId
}
}

View File

@ -8,6 +8,36 @@ import OSLog
protocol AppSettingsStore: AnyObject {
/// The app's unique identifier.
var appId: String? { get set }
/// The app's locale.
var appLocale: String? { get set }
/// The app's theme.
var appTheme: String? { get set }
/// Whether to disable the website icons.
var disableWebIcons: Bool { get set }
/// The user ID for the local user
var localUserId: String { get }
/// Gets the time after which the clipboard should be cleared.
///
/// - Parameter userId: The user ID associated with the clipboard clearing time.
///
/// - Returns: The time after which the clipboard should be cleared.
///
func clearClipboardValue(userId: String) -> ClearClipboardValue
/// Sets the time after which the clipboard should be cleared.
///
/// - Parameters:
/// - clearClipboardValue: The time after which the clipboard should be cleared.
/// - userId: The user ID associated with the clipboard clearing time.
///
/// - Returns: The time after which the clipboard should be cleared.
///
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String)
}
// MARK: - DefaultAppSettingsStore
@ -17,6 +47,8 @@ protocol AppSettingsStore: AnyObject {
class DefaultAppSettingsStore {
// MARK: Properties
let localUserId = "local"
/// The `UserDefauls` instance to persist settings.
let userDefaults: UserDefaults
@ -123,6 +155,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
///
enum Keys {
case appId
case appLocale
case appTheme
case disableWebIcons
case clearClipboardValue(userId: String)
/// Returns the key used to store the data under for retrieving it later.
var storageKey: String {
@ -130,6 +166,14 @@ extension DefaultAppSettingsStore: AppSettingsStore {
switch self {
case .appId:
key = "appId"
case .appLocale:
key = "appLocale"
case .appTheme:
key = "theme"
case let .clearClipboardValue(userId):
key = "clearClipboard_\(userId)"
case .disableWebIcons:
key = "disableFavicon"
}
return "bwaPreferencesStorage:\(key)"
}
@ -139,4 +183,31 @@ extension DefaultAppSettingsStore: AppSettingsStore {
get { fetch(for: .appId) }
set { store(newValue, for: .appId) }
}
var appLocale: String? {
get { fetch(for: .appLocale) }
set { store(newValue, for: .appLocale) }
}
var appTheme: String? {
get { fetch(for: .appTheme) }
set { store(newValue, for: .appTheme) }
}
var disableWebIcons: Bool {
get { fetch(for: .disableWebIcons) }
set { store(newValue, for: .disableWebIcons) }
}
func clearClipboardValue(userId: String) -> ClearClipboardValue {
if let rawValue: Int = fetch(for: .clearClipboardValue(userId: userId)),
let value = ClearClipboardValue(rawValue: rawValue) {
return value
}
return .never
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
store(clearClipboardValue?.rawValue, for: .clearClipboardValue(userId: userId))
}
}

View File

@ -0,0 +1,47 @@
import Foundation
// MARK: - ExternalLinksConstants
/// Links that are used throughout the app.
///
enum ExternalLinksConstants {
// MARK: Properties
/// A link to Bitwarden's organizations information webpage.
static let aboutOrganizations = URL(string: "https://bitwarden.com/help/about-organizations")!
/// A link to the app review page within the app store.
static let appReview = URL(string: "https://itunes.apple.com/us/app/id1137397744?action=write-review")
/// A link to Bitwarden's help page for learning more about the account fingerprint phrase.
static let fingerprintPhrase = URL(string: "https://bitwarden.com/help/fingerprint-phrase/")!
/// A link to Bitwarden's help page for generating username types.
static let generatorUsernameTypes = URL(string: "https://bitwarden.com/help/generator/#username-types")!
/// A link for beta users to provide feedback.
static let giveFeedback = URL(string: "https://livefrontinc.typeform.com/to/irgrRu4a")
/// A link to Bitwarden's general help and feedback page.
static let helpAndFeedback = URL(string: "http://bitwarden.com/help/")!
/// A link to Bitwarden's import items help webpage.
static let importItems = URL(string: "http://bitwarden.com/help/import-data/")!
/// A markdown link to Bitwarden's privacy policy.
static let privacyPolicy = URL(string: "https://bitwarden.com/privacy/")!
/// A markdown link to Bitwarden's help page about protecting individual items.
static let protectIndividualItems = URL(
string: "https://bitwarden.com/help/managing-items/#protect-individual-items"
)!
/// A link to Bitwarden's recovery code help page.
static let recoveryCode = URL(string: "https://bitwarden.com/help/lost-two-step-device/")!
/// A link to Bitwarden's product page for Sends.
static let sendInfo = URL(string: "https://bitwarden.com/products/send/")!
/// A markdown link to Bitwarden's terms of service.
static let termsOfService = URL(string: "https://bitwarden.com/terms/")!
}

View File

@ -0,0 +1,12 @@
import Foundation
// MARK: - FeatureFlagsConstants
/// An enumeration of feature flags.
///
enum FeatureFlagsConstants {
// MARK: Properties
/// A flag that enables individual cipher encryption.
static let enableCipherKeyEncryption = "enableCipherKeyEncryption"
}

View File

@ -39,7 +39,7 @@ extension AuthenticatorItem {
/// Data model for an unencrypted item
///
struct AuthenticatorItemView: Equatable, Sendable {
public struct AuthenticatorItemView: Equatable, Sendable, Hashable {
let id: String
let name: String
let totpKey: String?

View File

@ -11,6 +11,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// The types of modules used by this coordinator.
typealias Module = ItemListModule
& TabModule
// MARK: Private Properties
@ -59,7 +60,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
func handleEvent(_ event: AppEvent, context: AnyObject?) async {
switch event {
case .didStart:
showItemList(route: .list)
showTab(route: .itemList(.list))
// showItemList(route: .list)
}
}
@ -67,6 +69,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
switch route {
case .onboarding:
break
case let .tab(tabRoute):
showTab(route: tabRoute)
}
}
@ -95,4 +99,25 @@ class AppCoordinator: Coordinator, HasRootNavigator {
rootNavigator?.show(child: stackNavigator)
}
}
/// Shows the tab route.
///
/// - Parameter route: The tab route to show.
///
private func showTab(route: TabRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<TabRoute, Void> {
coordinator.navigate(to: route)
} else {
guard let rootNavigator else { return }
let tabNavigator = UITabBarController()
let coordinator = module.makeTabCoordinator(
errorReporter: services.errorReporter,
rootNavigator: rootNavigator,
tabNavigator: tabNavigator
)
coordinator.start()
coordinator.navigate(to: route)
childCoordinator = coordinator
}
}
}

View File

@ -1,47 +0,0 @@
import BitwardenSdk
/// A delegate that is used to handle actions and configure the display for when the app runs within
/// an app extension.
///
public protocol AppExtensionDelegate: AnyObject {
/// The app's route that the app should navigate to after auth has been completed.
var authCompletionRoute: AppRoute { get }
/// Whether the app is running within an extension.
var isInAppExtension: Bool { get }
/// Whether the app is running the save login flow in the action extension. This flow opens the
/// add vault item view and completes the extension request when the item has been added.
var isInAppExtensionSaveLoginFlow: Bool { get }
/// The URI of the credential to autofill.
var uri: String? { get }
/// The autofill request should be completed with the specified username and password.
///
/// - Parameters:
/// - username: The username to fill.
/// - password: The password to fill.
/// - fields: A list of additional fields to fill.
///
func completeAutofillRequest(username: String, password: String, fields: [(String, String)]?)
/// A cancel button was tapped to exit the extension.
///
func didCancel()
}
public extension AppExtensionDelegate {
/// Whether the app is running the save login flow in the action extension. This flow opens the
/// add vault item view and completes the extension request when the item has been added.
var isInAppExtensionSaveLoginFlow: Bool { false }
/// The autofill request should be completed with the specified username and password.
///
/// - Parameters:
/// - username: The username to fill.
/// - password: The password to fill.
/// - fields: A list of additional fields to fill.
///
func completeAutofillRequest(username: String, password: String, fields: [(String, String)]?) {}
}

View File

@ -33,6 +33,7 @@ public class AppProcessor {
self.appModule = appModule
self.services = services
UI.initialLanguageCode = services.appSettingsStore.appLocale ?? Locale.current.languageCode
UI.applyDefaultAppearances()
}
@ -57,6 +58,16 @@ public class AppProcessor {
coordinator.start()
self.coordinator = coordinator
Task {
for await appTheme in await services.stateService.appThemePublisher().values {
navigator.appTheme = appTheme
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
}
}
// TODO: Migration service
// await services.migrationService.performMigrations()
if let initialRoute {
coordinator.navigate(to: initialRoute)
} else {

View File

@ -0,0 +1,124 @@
import SwiftUI
/// A `ViewModifier` that causes a `View`'s `frame` to scale with dynamic font size.
///
struct ScaledFrame: ViewModifier {
// MARK: Private Properties
/// The current scale to use when calculating the actual frame size.
///
/// This value is a `ScaledMetric`, which bases the scaling on the current DynamicType setting.
@ScaledMetric private var scale = 1.0
/// The scaled representation of ``height``.
private var scaledHeight: CGFloat { height * scale }
/// The scaled representation of ``width``.
private var scaledWidth: CGFloat { width * scale }
// MARK: Properties
/// The height of the frame before scaling.
let height: CGFloat
/// The width of the frame before scaling.
let width: CGFloat
// MARK: Initialization
/// Creates a new `ScaledFrame` modifier that sets the frame of the view to a scaled
/// representation of the `height` and `width`, based on the user's current Dynamic
/// Type setting.
///
/// - Parameters:
/// - width: The width of the frame before scaling.
/// - height: The height of the frame before scaling.
///
init(
width: CGFloat,
height: CGFloat
) {
self.width = width
self.height = height
}
// MARK: View
func body(content: Content) -> some View {
content
.frame(width: scaledWidth, height: scaledHeight)
}
}
// MARK: View
extension View {
/// Sets the frame of a `View` to width/height values that can optionally be scaled with
/// dynamic font size.
///
/// - Parameters:
/// - width: The width of the view before scaling.
/// - height: The height of the view before scaling.
/// - scaleWithFont: Whether to scale the frame with dynamic font size.
///
@ViewBuilder
func frame(width: CGFloat, height: CGFloat, scaleWithFont: Bool) -> some View {
if scaleWithFont {
scaledFrame(width: width, height: height)
} else {
frame(width: width, height: height)
}
}
/// Set the frame of a `View` to width/height values that will scale with dynamic font size.
///
/// - Parameters:
/// - width: The width of the image before scaling
/// - height: The height of the image before scaling.
///
func scaledFrame(width: CGFloat, height: CGFloat) -> some View {
modifier(ScaledFrame(width: width, height: height))
}
}
// MARK: Image + ScaledFrame
extension Image {
/// Set the frame of an `Image` to width/height values that will scale with dynamic font size.
///
/// - Parameters:
/// - width: The width of the image before scaling
/// - height: The height of the image before scaling.
///
func scaledFrame(width: CGFloat, height: CGFloat) -> some View {
resizable()
.modifier(ScaledFrame(width: width, height: height))
}
}
#if DEBUG
#Preview {
VStack {
Image(systemName: "ruler.fill")
.aspectRatio(contentMode: .fit)
.scaledFrame(width: 24, height: 24)
.environment(\.sizeCategory, .extraSmall)
Image(systemName: "ruler.fill")
.aspectRatio(contentMode: .fit)
.scaledFrame(width: 24, height: 24)
.environment(\.sizeCategory, .extraExtraLarge)
Image(systemName: "ruler.fill")
.aspectRatio(contentMode: .fit)
.scaledFrame(width: 24, height: 24)
.environment(\.sizeCategory, .accessibilityMedium)
Image(systemName: "ruler.fill")
.aspectRatio(contentMode: .fit)
.scaledFrame(width: 24, height: 24)
.environment(\.sizeCategory, .accessibilityExtraLarge)
}
.previewLayout(.sizeThatFits)
}
#endif

View File

@ -0,0 +1,48 @@
import SwiftUI
extension Color {
// MARK: Initialization
/// Conveniently initializes a `Color` using a hex value.
///
/// - Parameter hex: The hex value as a string.
///
init(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
if Scanner(string: hexSanitized).scanHexInt64(&rgb) {
self.init(
red: CGFloat((rgb & 0xFF0000) >> 16) / 255.0,
green: CGFloat((rgb & 0x00FF00) >> 8) / 255.0,
blue: CGFloat(rgb & 0x0000FF) / 255.0
)
} else {
self.init(red: 0, green: 0, blue: 0)
}
}
// MARK: Methods
/// Determines whether a `Color` is considered light based off of its luminance.
///
/// When a light color is used as a background, the overlaid text should be dark.
///
/// - Returns: Whether a `Color` is considered light based off of its luminance.
///
func isLight() -> Bool {
// algorithm from: http://www.w3.org/WAI/ER/WD-AERT/#color-contrast
let uiColor = UIColor(self)
var red: CGFloat = 0.0
var green: CGFloat = 0.0
var blue: CGFloat = 0.0
var alpha: CGFloat = 0.0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let luminance = ((red * 299) + (green * 587) + (blue * 114)) / 1000
return luminance >= 0.65
}
}

View File

@ -0,0 +1,119 @@
import Foundation
import SwiftUI
// MARK: - URLDecodingError
/// Errors that can be encountered when attempting to decode a string from it's url encoded format.
enum URLDecodingError: Error, Equatable {
/// The provided string is an invalid length.
///
/// Base64 encoded strings are padded at the end with `=` characters to ensure that the length of the resulting
/// value is divisible by `4`. However, Base64 encoded strings _cannot_ have a remainder of `1` when divided by
/// `4`.
///
/// Example: `YMFhY` is considered invalid, and attempting to decode this value from a url or header value will
/// throw this error.
///
case invalidLength
}
// MARK: - String
extension String {
// MARK: Type Properties
/// Double paragraph breaks to show the next line of text separated by a blank line.
static let newLine = "\n\n"
/// A zero width space. https://symbl.cc/en/200B/
static let zeroWidthSpace = "\u{200B}"
// MARK: Properties
/// Returns a color that's generated from the hash of the characters in the string. This can be
/// used to create a consistent color based on the provided string.
var hashColor: Color {
let hash = unicodeScalars.reduce(into: 0) { result, scalar in
result = Int(scalar.value) + ((result << 5) &- result)
}
let color = (0 ..< 3).reduce(into: "#") { result, index in
let value = (hash >> (index * 8)) & 0xFF
result += String(value, radix: 16).leftPadding(toLength: 2, withPad: "0")
}
return Color(hex: color)
}
/// A flag indicating if this string is considered a valid email address or not.
///
/// An email is considered valid if it has at least one `@` symbol in it.
var isValidEmail: Bool {
contains("@")
}
/// Returns `true` if the URL is valid.
var isValidURL: Bool {
guard rangeOfCharacter(from: .whitespaces) == nil else { return false }
let urlString: String
if starts(with: "https://") || starts(with: "http://") {
urlString = self
} else {
urlString = "https://" + self
}
if #available(iOS 16, *) {
return (try? URL(urlString, strategy: .url)) != nil
} else {
return URL(string: urlString) != nil
}
}
// MARK: Methods
/// Returns a copy of the string, padded to the specified length on the left side with the
/// provided padding character.
///
/// - Parameters:
/// - toLength: The length of the string to return. If the string's length is less than this,
/// it will be padded with the provided character on the left/leading side.
/// - character: The character to use for padding.
/// - Returns: A copy of the string, padded to the specified length on the left side.
///
func leftPadding(toLength: Int, withPad character: Character) -> String {
if count < toLength {
return String(repeatElement(character, count: toLength - count)) + self
} else {
return String(suffix(toLength))
}
}
/// Creates a new string that has been encoded for use in a url or request header.
///
/// - Returns: A `String` encoded for use in a url or request header.
///
func urlEncoded() -> String {
replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// Creates a new string that has been decoded from a url or request header.
///
/// - Throws: `URLDecodingError.invalidLength` if the length of this string is invalid.
///
/// - Returns: A `String` decoded from use in a url or request header.
///
func urlDecoded() throws -> String {
let remainder = count % 4
guard remainder != 1 else { throw URLDecodingError.invalidLength }
return replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
.appending(String(
repeating: "=",
count: remainder == 0 ? 0 : 4 - remainder
))
}
}

View File

@ -0,0 +1,98 @@
import XCTest
@testable import AuthenticatorShared
// MARK: - StringTests
class StringTests: AuthenticatorTestCase {
// MARK: Tests
/// `hashColor` returns a color generated from a hash of the string's characters.
func test_hashColor() {
XCTAssertEqual("test".hashColor.description, "#924436FF")
XCTAssertEqual("0620ee30-91c3-40cb-8fad-b102005c35b0".hashColor.description, "#32F23FFF")
XCTAssertEqual("9c303aee-e636-4760-94b6-e4951d7b0abb".hashColor.description, "#C96CD2FF")
}
/// `isValidEmail` with an invalid string returns `false`.
func test_isValidEmail_withInvalidString() {
let subjects = [
"",
"e",
"email",
"example.com",
]
// All strings should _not_ be considered valid emails
XCTAssertTrue(subjects.allSatisfy { string in
!string.isValidEmail
})
}
/// `isValidEmail` with a valid string returns `true`.
func test_isValidEmail_withValidString() {
let subjects = [
"email@example.com",
"e@e.c",
"email@example",
"email@example.",
"@example.com",
"email@.com",
"example.com@email",
"@@example.com",
" @example.com",
" email@example.com",
"email@example.com ",
]
XCTAssertTrue(subjects.allSatisfy(\.isValidEmail))
}
/// `isValidURL` returns `true` for a valid URL.
func test_isValidURL_withValidURL() {
XCTAssertTrue("http://bitwarden.com".isValidURL)
XCTAssertTrue("https://bitwarden.com".isValidURL)
XCTAssertTrue("bitwarden.com".isValidURL)
}
/// `isValidURL` returns `true` for an invalid URL.
func test_isValidURL_withInvalidURL() {
XCTAssertFalse(" ".isValidURL)
XCTAssertFalse("a b c".isValidURL)
XCTAssertFalse("a<b>c".isValidURL)
XCTAssertFalse("a[b]c".isValidURL)
}
/// `leftPadding(toLength:withPad:)` returns a string padded to the specified length.
func test_leftPadding() {
XCTAssertEqual("".leftPadding(toLength: 2, withPad: "0"), "00")
XCTAssertEqual("AB".leftPadding(toLength: 4, withPad: "0"), "00AB")
XCTAssertEqual("ABCD".leftPadding(toLength: 4, withPad: "0"), "ABCD")
XCTAssertEqual("ABCDEF".leftPadding(toLength: 4, withPad: "0"), "CDEF")
}
/// `urlDecoded()` with an invalid string throws an error.
func test_urlDecoded_withInvalidString() {
let subject = "a_bc-"
XCTAssertThrowsError(try subject.urlDecoded()) { error in
XCTAssertEqual(error as? URLDecodingError, .invalidLength)
}
}
/// `urlDecoded()` with a valid string returns the decoded string.
func test_urlDecoded_withValidString() throws {
let subject = "a_bcd-"
let decoded = try subject.urlDecoded()
XCTAssertEqual(decoded, "a/bcd+==")
}
/// `urlEncoded()` returns the encoded string.
func test_urlEncoded() {
let subject = "a/bcd+=="
let encoded = subject.urlEncoded()
XCTAssertEqual(encoded, "a_bcd-")
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "check.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "external-link-2.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "external-link.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "gear-filled.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "locked-filled.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "right-angle.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -1,7 +1,11 @@
/// A top level route from the initial screen of the app to anywhere in the app.
///
public enum AppRoute: Equatable {
/// A route to the onboarding experience.
case onboarding
/// A route to the tab interface.
case tab(TabRoute)
}
public enum AppEvent: Equatable {

View File

@ -13,7 +13,18 @@ public protocol TabNavigator: Navigator {
///
/// - Parameter tab: The tab which should be returned by the navigator.
/// - Returns: The child navigator for the specified tab.
func navigator<Tab: RawRepresentable>(for tab: Tab) -> Navigator? where Tab.RawValue == Int
///
func navigator<Tab: TabRepresentable>(for tab: Tab) -> Navigator?
/// Sets the child navigators for their tabs.
///
/// This method replaces all existing tabs with this new set of tabs.
///
/// Tabs are ordered based on their `index` value.
///
/// - Parameter tabs: The tab -> navigator relationship.
///
func setNavigators<Tab: Hashable & TabRepresentable>(_ tabs: [Tab: Navigator])
}
// MARK: - UITabBarController
@ -23,7 +34,19 @@ extension UITabBarController: TabNavigator {
self
}
public func navigator<Tab: RawRepresentable>(for tab: Tab) -> Navigator? where Tab.RawValue == Int {
viewControllers?[tab.rawValue] as? Navigator
public func navigator<Tab: TabRepresentable>(for tab: Tab) -> Navigator? {
viewControllers?[tab.index] as? Navigator
}
public func setNavigators<Tab: Hashable & TabRepresentable>(_ tabs: [Tab: Navigator]) {
viewControllers = tabs
.sorted { $0.key.index < $1.key.index }
.compactMap { tab in
guard let viewController = tab.value.rootViewController else { return nil }
viewController.tabBarItem.title = tab.key.title
viewController.tabBarItem.image = tab.key.image
viewController.tabBarItem.selectedImage = tab.key.selectedImage
return viewController
}
}
}

View File

@ -0,0 +1,343 @@
// MARK: - Alert + Settings
extension Alert {
// MARK: Methods
/// An alert that asks if the user wants to navigate to the app store to leave a review.
///
/// - Parameter action: The action taken if they select continue.
/// - Returns: An alert that asks if the user wants to navigate to the app store to leave a review.
///
static func appStoreAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToAppStore,
message: Localizations.rateAppDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
/// Confirm allowing the device to approve login requests.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert confirming allowing the device to approve login requests.
///
static func confirmApproveLoginRequests(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.approveLoginRequests,
message: Localizations.useThisDeviceToApproveLoginRequestsMadeFromOtherDevices,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
]
)
}
/// Confirm deleting the folder.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert to confirm deleting the folder.
///
static func confirmDeleteFolder(action: @MainActor @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.doYouReallyWantToDelete,
message: nil,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
AlertAction(title: Localizations.no, style: .cancel),
]
)
}
/// Confirm denying all the login requests.
///
/// - Parameter action: The action to perform if the user selects yes.
///
/// - Returns: An alert to confirm denying all the login requests.
///
static func confirmDenyingAllRequests(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.areYouSureYouWantToDeclineAllPendingLogInRequests,
message: nil,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
]
)
}
/// Confirm that the user wants to export their vault.
///
/// - Parameters:
/// - encrypted: Whether the user is attempting to export their vault encrypted or not.
/// - action: The action performed when they select export vault.
///
/// - Returns: An alert confirming that the user wants to export their vault unencrypted.
///
static func confirmExportVault(encrypted: Bool, action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.exportVaultConfirmationTitle,
message: encrypted ?
(Localizations.encExportKeyWarning + .newLine + Localizations.encExportAccountWarning) :
Localizations.exportVaultWarning,
alertActions: [
AlertAction(title: Localizations.exportVault, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
]
)
}
/// Displays the account fingerprint phrase alert.
///
/// - Parameters:
/// - phrase: The user's fingerprint phrase.
/// - action: The action to perform when the user selects `Learn more`.
///
/// - Returns: An alert that displays the user's fingerprint phrase and prompts them to learn more about it.
///
static func displayFingerprintPhraseAlert(phrase: String, action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.fingerprintPhrase,
message: "\(Localizations.yourAccountsFingerprint):\n\n\(phrase)",
alertActions: [
AlertAction(title: Localizations.close, style: .cancel),
AlertAction(title: Localizations.learnMore, style: .default) { _ in await action() },
]
)
}
/// An alert that prompts the user to enter their PIN.
///
/// - Parameter completion: The code block that's executed when the user has entered their pin.
/// - Returns: An alert that prompts the user to enter their PIN.
///
static func enterPINCode(completion: @MainActor @escaping (String) async -> Void) -> Alert {
Alert(
title: Localizations.enterPIN,
message: Localizations.setPINDescription,
alertActions: [
AlertAction(
title: Localizations.submit,
style: .default,
handler: { _, alertTextFields in
guard let password = alertTextFields.first(where: { $0.id == "pin" })?.text else { return }
await completion(password)
}
),
AlertAction(title: Localizations.cancel, style: .cancel),
],
alertTextFields: [
AlertTextField(
id: "pin",
autocapitalizationType: .none,
autocorrectionType: .no,
keyboardType: .numberPad
),
]
)
}
/// An alert verifying that the user wants to navigate to the web browser to submit feedback.
///
/// - Parameter action: The action to take if the user selects `Yes`.
/// - Returns: An alert verifying that the user wants to navigate to the web browser to submit feedback.
///
static func giveFeedbackAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToGiveFeedback,
message: Localizations.continueToGiveFeedbackDescription,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
/// An alert that asks if the user wants to navigate to the "import items" page in a browser.
///
/// - Parameter action: The action taken if they select continue.
/// - Returns: An alert that asks if the user wants to navigate to the import items page.
///
static func importItemsAlert(importUrl: String, action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToWebApp,
message: Localizations.youCanImportDataToYourVaultOnX(importUrl),
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
/// Show the alert notifying the user that the language has been changed.
///
/// - Parameters:
/// - newLanguage: The title of the new language.
/// - action: The action to run after the user clicks ok.
/// - Returns: An alert confirming the language change.
///
@MainActor
static func languageChanged(to newLanguage: String, action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.languageChangeXDescription(newLanguage),
message: nil,
alertActions: [
AlertAction(title: Localizations.ok, style: .default) { _ in
action()
},
]
)
}
/// An alert that asks if the user wants to navigate to the "learn about organizations" help page in a browser.
///
/// - Parameter action: The action taken if they select continue.
/// - Returns: An alert that asks if the user wants to navigate to the app store to leave a review.
///
static func learnAboutOrganizationsAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.learnOrg,
message: Localizations.learnAboutOrganizationsDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
/// Confirms that the user wants to logout if their session times out.
///
/// - Parameter action: The action performed when they select `Yes`.
///
/// - Returns: An alert confirming that the user wants to logout if their session times out.
///
static func logoutOnTimeoutAlert(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.warning,
message: Localizations.vaultTimeoutLogOutConfirmation,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
]
)
}
/// Confirms that the user wants to set their vault timeout to never.
///
/// - Parameter action: The action performed when they select `Yes`.
///
/// - Returns: An alert confirming that the user wants to set their vault timeout to never.
///
static func neverLockAlert(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.warning,
message: Localizations.neverLockWarning,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
]
)
}
/// An alert that asks if the user wants to navigate to the privacy policy in a browser.
///
/// - Parameter action: The action taken if they select continue.
/// - Returns: An alert that asks if the user wants to navigate to the app store to leave a review.
///
static func privacyPolicyAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToPrivacyPolicy,
message: Localizations.privacyPolicyDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
/// Alerts the user that their selected timeout value exceeds the policy's limit.
///
/// - Returns an alert notifying the user that their selected timeout value exceeds the policy's limit.
///
static func timeoutExceedsPolicyLengthAlert() -> Alert {
Alert(
title: Localizations.warning,
message: Localizations.vaultTimeoutToLarge,
alertActions: [
AlertAction(title: Localizations.ok, style: .default),
]
)
}
/// An alert notifying the user that they will be navigated to the web app to set up two step login.
///
/// - Parameter action: The action to perform when the user confirms that they want to be navigated to the
/// web app.
///
/// - Returns: An alert notifying the user that they will be navigated to the web app to set up two step login.
///
static func twoStepLoginAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToWebApp,
message: Localizations.twoStepLoginDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.yes, style: .default) { _ in
action()
},
]
)
}
/// An alert asking if the user wants to login with their PIN upon app restart.
///
/// - Parameter action: The action to occur if `Yes` is tapped.
/// - Returns: An alert asking if the user wants to login with their PIN upon app restart.
///
static func unlockWithPINCodeAlert(action: @escaping (Bool) async -> Void) -> Alert {
Alert(
title: Localizations.unlockWithPIN,
message: Localizations.pinRequireMasterPasswordRestart,
alertActions: [
AlertAction(title: Localizations.no, style: .cancel) { _ in
await action(false)
},
AlertAction(title: Localizations.yes, style: .default) { _ in
await action(true)
},
]
)
}
/// An alert that asks if the user wants to navigate to the web vault in a browser.
///
/// - Parameter action: The action taken if they select continue.
/// - Returns: An alert that asks if the user wants to navigate to the web vault to leave a review.
///
static func webVaultAlert(action: @escaping () -> Void) -> Alert {
Alert(
title: Localizations.continueToWebApp,
message: Localizations.exploreMoreFeaturesOfYourBitwardenAccountOnTheWebApp,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
action()
},
]
)
}
}

View File

@ -0,0 +1,240 @@
import XCTest
@testable import AuthenticatorShared
class AlertSettingsTests: AuthenticatorTestCase {
/// `appStoreAlert(action:)` constructs an `Alert` with the title,
/// message, cancel, and continue buttons to confirm navigating to the app store.
func test_appStoreAlert() {
let subject = Alert.appStoreAlert {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.continueToAppStore)
XCTAssertEqual(subject.message, Localizations.rateAppDescriptionLong)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.continue)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmApproveLoginRequests(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm approving login requests
func test_confirmApproveLoginRequests() {
let subject = Alert.confirmApproveLoginRequests {}
XCTAssertEqual(subject.title, Localizations.approveLoginRequests)
XCTAssertEqual(subject.message, Localizations.useThisDeviceToApproveLoginRequestsMadeFromOtherDevices)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.no)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmDeleteFolder(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting a folder.
func test_confirmDeleteFolder() {
let subject = Alert.confirmDeleteFolder {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDelete)
XCTAssertNil(subject.message)
}
/// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm denying all login requests
func test_confirmDenyingAllRequests() {
let subject = Alert.confirmDenyingAllRequests {}
XCTAssertEqual(subject.title, Localizations.areYouSureYouWantToDeclineAllPendingLogInRequests)
XCTAssertNil(subject.message)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.no)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmExportVault(encrypted:action:)` constructs an `Alert` with the title, message, and Yes and Export vault
/// buttons.
func test_confirmExportVault() {
var subject = Alert.confirmExportVault(encrypted: true) {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.exportVaultConfirmationTitle)
XCTAssertEqual(
subject.message,
Localizations.encExportKeyWarning + .newLine + Localizations.encExportAccountWarning
)
subject = Alert.confirmExportVault(encrypted: false) {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.exportVaultConfirmationTitle)
XCTAssertEqual(subject.message, Localizations.exportVaultWarning)
}
/// `displayFingerprintPhraseAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Learn More buttons.
func test_displayFingerprintPhraseAlert() {
let subject = Alert.displayFingerprintPhraseAlert(phrase: "phrase") {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.fingerprintPhrase)
XCTAssertEqual(subject.message, "\(Localizations.yourAccountsFingerprint):\n\nphrase")
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.close)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.learnMore)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `enterPINCode(completion:)` constructs an `Alert` with the correct title, message, Submit and Cancel buttons.
func test_enterPINCodeAlert() {
let subject = Alert.enterPINCode { _ in }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.enterPIN)
XCTAssertEqual(subject.message, Localizations.setPINDescription)
}
/// `importItemsAlert(vaultUrl:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Continue buttons.
func test_importItemsAlert() {
let subject = Alert.importItemsAlert(importUrl: "https://www.example.com") {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.continueToWebApp)
XCTAssertEqual(subject.message, Localizations.youCanImportDataToYourVaultOnX("https://www.example.com"))
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.continue)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `languageChanged(to:)` constructs an `Alert` with the title and ok buttons.
func test_languageChanged() {
let subject = Alert.languageChanged(to: "Thai") {}
XCTAssertEqual(subject.title, Localizations.languageChangeXDescription("Thai"))
XCTAssertNil(subject.message)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.alertActions.count, 1)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.ok)
XCTAssertEqual(subject.alertActions.first?.style, .default)
}
/// `learnAboutOrganizationsAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Continue buttons.
func test_learnAboutOrganizationsAlert() {
let subject = Alert.learnAboutOrganizationsAlert {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.learnOrg)
XCTAssertEqual(subject.message, Localizations.learnAboutOrganizationsDescriptionLong)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.continue)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `logoutOnTimeoutAlert(action:)` constructs an `Alert` with the title, message, and Yes and Cancel buttons.
func test_logoutOnTimeoutAlert() {
let subject = Alert.logoutOnTimeoutAlert {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.warning)
XCTAssertEqual(subject.message, Localizations.vaultTimeoutLogOutConfirmation)
}
/// `neverLockAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Yes and Cancel buttons.
func test_neverLockAlert() {
let subject = Alert.neverLockAlert {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.warning)
XCTAssertEqual(subject.message, Localizations.neverLockWarning)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.first?.style, .default)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.last?.style, .cancel)
}
/// `privacyPolicyAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Continue buttons.
func test_privacyPolicyAlert() {
let subject = Alert.privacyPolicyAlert {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.continueToPrivacyPolicy)
XCTAssertEqual(subject.message, Localizations.privacyPolicyDescriptionLong)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.continue)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `timeoutExceedsPolicyLengthAlert()` constructs an `Alert` with the correct title, message, and Ok button.
func test_timeoutExceedsPolicyLengthAlert() {
let subject = Alert.timeoutExceedsPolicyLengthAlert()
XCTAssertEqual(subject.title, Localizations.warning)
XCTAssertEqual(subject.message, Localizations.vaultTimeoutToLarge)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.ok)
XCTAssertEqual(subject.alertActions.count, 1)
XCTAssertEqual(subject.alertActions.first?.style, .default)
}
/// `twoStepLoginAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Yes buttons.
func test_twoStepLoginAlert() {
let subject = Alert.twoStepLoginAlert {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.continueToWebApp)
XCTAssertEqual(subject.message, Localizations.twoStepLoginDescriptionLong)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.yes)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `unlockWithPINCodeAlert(action)` constructs an `Alert` with the correct title, message, Yes and No buttons.
func test_unlockWithPINAlert() {
let subject = Alert.unlockWithPINCodeAlert { _ in }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.unlockWithPIN)
XCTAssertEqual(subject.message, Localizations.pinRequireMasterPasswordRestart)
}
/// `webVaultAlert(encrypted:action:)` constructs an `Alert`
/// with the correct title, message, and Cancel and Continue buttons.
func test_webVaultAlert() {
let subject = Alert.webVaultAlert {}
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.continueToWebApp)
XCTAssertEqual(subject.message, Localizations.exploreMoreFeaturesOfYourBitwardenAccountOnTheWebApp)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions.first?.title, Localizations.cancel)
XCTAssertEqual(subject.alertActions.first?.style, .cancel)
XCTAssertEqual(subject.alertActions.last?.title, Localizations.continue)
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
}

View File

@ -0,0 +1,41 @@
// MARK: - AboutAction
/// Actions handled by the `AboutProcessor`.
///
enum AboutAction: Equatable {
/// Clears the app review URL.
case clearAppReviewURL
/// Clears the give feedback URL.
case clearGiveFeedbackURL
/// The url has been opened so clear the value in the state.
case clearURL
/// The give feedback button was tapped.
case giveFeedbackTapped
/// The help center button was tapped.
case helpCenterTapped
/// The learn about organizations button was tapped.
case learnAboutOrganizationsTapped
/// The privacy policy button was tapped.
case privacyPolicyTapped
/// The rate the app button was tapped.
case rateTheAppTapped
/// The toast was shown or hidden.
case toastShown(Toast?)
/// The submit crash logs toggle value changed.
case toggleSubmitCrashLogs(Bool)
/// The version was tapped.
case versionTapped
/// The web vault button was tapped.
case webVaultTapped
}

View File

@ -0,0 +1,92 @@
// MARK: - AboutProcessor
/// The processor used to manage state and handle actions for the `AboutView`.
///
final class AboutProcessor: StateProcessor<AboutState, AboutAction, Void> {
// MARK: Types
typealias Services = HasErrorReporter
& HasPasteboardService
// MARK: Properties
/// The coordinator used to manage navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services used by this processor.
private let services: Services
// MARK: Initialization
/// Initializes a new `AboutProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used to manage navigation.
/// - services: The services used by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AboutState
) {
self.coordinator = coordinator
self.services = services
// Set the initial value of the crash logs toggle.
var state = state
state.isSubmitCrashLogsToggleOn = self.services.errorReporter.isEnabled
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: AboutAction) {
switch action {
case .clearAppReviewURL:
state.appReviewUrl = nil
case .clearGiveFeedbackURL:
state.giveFeedbackUrl = nil
case .clearURL:
state.url = nil
case .giveFeedbackTapped:
coordinator.showAlert(.giveFeedbackAlert {
self.state.giveFeedbackUrl = ExternalLinksConstants.giveFeedback
})
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .learnAboutOrganizationsTapped:
coordinator.showAlert(.learnAboutOrganizationsAlert {
self.state.url = ExternalLinksConstants.aboutOrganizations
})
case .privacyPolicyTapped:
coordinator.showAlert(.privacyPolicyAlert {
self.state.url = ExternalLinksConstants.privacyPolicy
})
case .rateTheAppTapped:
coordinator.showAlert(.appStoreAlert {
self.state.appReviewUrl = ExternalLinksConstants.appReview
})
case let .toastShown(newValue):
state.toast = newValue
case let .toggleSubmitCrashLogs(isOn):
state.isSubmitCrashLogsToggleOn = isOn
services.errorReporter.isEnabled = isOn
case .versionTapped:
handleVersionTapped()
case .webVaultTapped:
break
}
}
// MARK: - Private Methods
/// Prepare the text to be copied.
private func handleVersionTapped() {
// Copy the copyright text followed by the version info.
let text = state.copyrightText + "\n\n" + state.version
services.pasteboardService.copy(text)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.appInfo))
}
}

View File

@ -0,0 +1,172 @@
import XCTest
@testable import AuthenticatorShared
class AboutProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var environmentService: MockEnvironmentService!
var errorReporter: MockErrorReporter!
var pasteboardService: MockPasteboardService!
var subject: AboutProcessor!
// MARK: Setup and Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator<SettingsRoute, SettingsEvent>()
environmentService = MockEnvironmentService()
errorReporter = MockErrorReporter()
pasteboardService = MockPasteboardService()
subject = AboutProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
environmentService: environmentService,
errorReporter: errorReporter,
pasteboardService: pasteboardService
),
state: AboutState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
environmentService = nil
errorReporter = nil
pasteboardService = nil
subject = nil
}
// MARK: Tests
/// `init` sets the correct crash logs setting.
func test_init_loadsValues() {
errorReporter.isEnabled = true
subject = AboutProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
errorReporter: errorReporter
),
state: AboutState()
)
XCTAssertTrue(subject.state.isSubmitCrashLogsToggleOn)
}
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
func test_receive_clearAppReviewURL() {
subject.state.appReviewUrl = .example
subject.receive(.clearAppReviewURL)
XCTAssertNil(subject.state.appReviewUrl)
}
/// `receive(_:)` with `.clearGiveFeedbackURL` clears the URL in the state.
func test_receive_clearGiveFeedbackURL() {
subject.state.url = URL(string: "example.com")
subject.receive(.clearGiveFeedbackURL)
XCTAssertNil(subject.state.giveFeedbackUrl)
}
/// `receive(_:)` with `.clearURL` clears the URL in the state.
func test_receive_clearURL() {
subject.state.url = .example
subject.receive(.clearURL)
XCTAssertNil(subject.state.url)
}
/// `receive(_:)` with `.giveFeedbackTapped` populates the URL in the state.
func test_receive_giveFeedback() async throws {
subject.receive(.giveFeedbackTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
// Tapping continue navigates the user to the web app.
try await alert.tapAction(title: Localizations.continue)
XCTAssertNotNil(subject.state.giveFeedbackUrl)
}
/// `receive(_:)` with `.helpCenterTapped` set the URL to open in the state.
func test_receive_helpCenterTapped() {
subject.receive(.helpCenterTapped)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.helpAndFeedback)
}
/// `receive(_:)` with `.learnAboutOrganizationsTapped` shows an alert for navigating to the website
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
func test_receive_learnAboutOrganizationsTapped() async throws {
subject.receive(.learnAboutOrganizationsTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.aboutOrganizations)
}
/// `receive(_:)` with `.privacyPolicyTapped` shows an alert for navigating to the Privacy Policy
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
func test_receive_privacyPolicyTapped() async throws {
subject.receive(.privacyPolicyTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.privacyPolicy)
}
/// `receive(_:)` with `.rateTheAppTapped` shows an alert for navigating to the app store.
/// When `Continue` is tapped on the alert, the `appReviewUrl` is populated.
func test_receive_rateTheAppTapped() async throws {
subject.receive(.rateTheAppTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(
subject.state.appReviewUrl?.absoluteString,
"https://itunes.apple.com/us/app/id1137397744?action=write-review"
)
}
/// `receive(_:)` with `.toastShown` updates the state's toast value.
func test_receive_toastShown() {
let toast = Toast(text: "toast!")
subject.receive(.toastShown(toast))
XCTAssertEqual(subject.state.toast, toast)
subject.receive(.toastShown(nil))
XCTAssertNil(subject.state.toast)
}
/// `receive(_:)` with action `.isSubmitCrashLogsToggleOn` updates the toggle value in the state.
func test_receive_toggleSubmitCrashLogs() {
errorReporter.isEnabled = false
XCTAssertFalse(subject.state.isSubmitCrashLogsToggleOn)
subject.receive(.toggleSubmitCrashLogs(true))
XCTAssertTrue(subject.state.isSubmitCrashLogsToggleOn)
XCTAssertTrue(errorReporter.isEnabled)
}
/// `receive(_:)` with action `.versionTapped` copies the version string to the pasteboard.
func test_receive_versionTapped() {
subject.receive(.versionTapped)
let text = subject.state.copyrightText + "\n\n" + subject.state.version
XCTAssertEqual(pasteboardService.copiedString, text)
XCTAssertEqual(subject.state.toast?.text, Localizations.valueHasBeenCopied(Localizations.appInfo))
}
/// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
func test_receive_webVaultTapped() async throws {
subject.receive(.webVaultTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(subject.state.url, environmentService.webVaultURL)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
// MARK: - AboutState
/// An object that defines the current state of the `AboutView`.
///
struct AboutState {
/// The URL for Bitwarden's app review page in the app store.
var appReviewUrl: URL?
/// The copyright text.
var copyrightText = "© Bitwarden Inc. 2015-\(Calendar.current.component(.year, from: Date.now))"
/// The URL of the feedback webpage.
var giveFeedbackUrl: URL?
/// Whether the submit crash logs toggle is on.
var isSubmitCrashLogsToggleOn: Bool = false
/// A toast message to show in the view.
var toast: Toast?
/// The url to open in the device's web browser.
var url: URL?
/// The version of the app.
var version: String = "\(Localizations.version): \(Bundle.main.appVersion) (\(Bundle.main.buildNumber))"
}

View File

@ -0,0 +1,117 @@
import SwiftUI
// MARK: - AboutView
/// A view that allows users to view miscellaneous information about the app.
///
struct AboutView: View {
// MARK: Properties
/// An object used to open urls from this view.
@Environment(\.openURL) private var openURL
/// The `Store` for this view.
@ObservedObject var store: Store<AboutState, AboutAction, Void>
// MARK: View
var body: some View {
VStack(alignment: .leading, spacing: 16) {
submitCrashLogs
miscSection
copyrightNotice
}
.scrollView()
.navigationBar(title: Localizations.about, titleDisplayMode: .inline)
.toast(store.binding(
get: \.toast,
send: AboutAction.toastShown
))
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearURL)
}
.onChange(of: store.state.appReviewUrl) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearAppReviewURL)
}
.onChange(of: store.state.giveFeedbackUrl) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearGiveFeedbackURL)
}
}
// MARK: Private views
/// The copyright notice.
private var copyrightNotice: some View {
Text(store.state.copyrightText)
.styleGuide(.caption2)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
/// The section of miscellaneous about items.
private var miscSection: some View {
VStack(spacing: 0) {
externalLinkRow(Localizations.bitwardenHelpCenter, action: .helpCenterTapped)
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
externalLinkRow(Localizations.webVault, action: .webVaultTapped)
externalLinkRow(Localizations.learnOrg, action: .learnAboutOrganizationsTapped)
externalLinkRow(Localizations.giveFeedback, action: .giveFeedbackTapped)
SettingsListItem(store.state.version, hasDivider: false) {
store.send(.versionTapped)
} trailingContent: {
Asset.Images.copy.swiftUIImage
.imageStyle(.rowIcon)
}
}
.cornerRadius(10)
}
/// The submit crash logs toggle.
private var submitCrashLogs: some View {
Toggle(isOn: store.binding(
get: \.isSubmitCrashLogsToggleOn,
send: AboutAction.toggleSubmitCrashLogs
)) {
Text(Localizations.submitCrashLogs)
}
.toggleStyle(.bitwarden)
.styleGuide(.body)
.accessibilityIdentifier("SubmitCrashLogsSwitch")
}
/// Returns a `SettingsListItem` configured for an external web link.
///
/// - Parameters:
/// - name: The localized name of the row.
/// - action: An action to send when the row is tapped.
/// - Returns: A `SettingsListItem` configured for an external web link.
///
private func externalLinkRow(_ name: String, action: AboutAction) -> some View {
SettingsListItem(name) {
store.send(action)
} trailingContent: {
Asset.Images.externalLink2.swiftUIImage
.imageStyle(.rowIcon)
}
}
}
// MARK: - Previews
#Preview {
AboutView(store: Store(processor: StateProcessor(state: AboutState())))
}

View File

@ -0,0 +1,83 @@
import SnapshotTesting
import XCTest
@testable import AuthenticatorShared
class AboutViewTests: AuthenticatorTestCase {
// MARK: Properties
let copyrightText = "© Bitwarden Inc. 2015-2023"
let version = "Version: 1.0.0 (1)"
var processor: MockProcessor<AboutState, AboutAction, Void>!
var subject: AboutView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: AboutState(copyrightText: copyrightText, version: version))
let store = Store(processor: processor)
subject = AboutView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the give feedback button dispatches the `.giveFeedbackTapped` action.
func test_giveFeedbackButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.giveFeedback)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .giveFeedbackTapped)
}
/// Tapping the help center button dispatches the `.helpCenterTapped` action.
func test_helpCenterButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.bitwardenHelpCenter)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .helpCenterTapped)
}
/// Tapping the privacy policy button dispatches the `.privacyPolicyTapped` action.
func test_privacyPolicyButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.privacyPolicy)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .privacyPolicyTapped)
}
/// Tapping the learn about organizations button dispatches the `.learnAboutOrganizationsTapped` action.
func test_learnAboutOrganizationsButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.learnOrg)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .learnAboutOrganizationsTapped)
}
/// Tapping the version button dispatches the `.versionTapped` action.
func test_versionButton_tap() throws {
let button = try subject.inspect().find(button: version)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .versionTapped)
}
/// Tapping the web vault button dispatches the `.webVaultTapped` action.
func test_webVaultButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.webVault)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .webVaultTapped)
}
// MARK: Snapshots
/// The default view renders correctly.
func test_snapshot_default() {
assertSnapshots(of: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -0,0 +1,14 @@
// MARK: - AppearanceAction
/// Actions handled by the `AppearanceProcessor`.
///
enum AppearanceAction: Equatable {
/// The default color theme was changed.
case appThemeChanged(AppTheme)
/// The language option was tapped.
case languageTapped
/// Show website icons was toggled.
case toggleShowWebsiteIcons(Bool)
}

View File

@ -0,0 +1,7 @@
// MARK: - AppearanceEffect
/// Effects that can be processed by an `AppearanceProcessor`.
enum AppearanceEffect {
/// The view appeared so the initial data should be loaded.
case loadData
}

View File

@ -0,0 +1,76 @@
import Foundation
// MARK: - AppearanceProcessor
/// The processor used to manage state and handle actions for the `AppearanceView`.
///
final class AppearanceProcessor: StateProcessor<AppearanceState, AppearanceAction, AppearanceEffect> {
// MARK: Types
typealias Services = HasStateService
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services for this processor.
private var services: Services
// MARK: Initialization
/// Initializes a new `AppearanceProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
/// - services: The services for this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AppearanceState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: AppearanceEffect) async {
switch effect {
case .loadData:
state.currentLanguage = services.stateService.appLanguage
state.appTheme = await services.stateService.getAppTheme()
state.isShowWebsiteIconsToggleOn = await services.stateService.getShowWebIcons()
}
}
override func receive(_ action: AppearanceAction) {
switch action {
case let .appThemeChanged(appTheme):
state.appTheme = appTheme
Task {
await services.stateService.setAppTheme(appTheme)
}
case .languageTapped:
coordinator.navigate(to: .selectLanguage(currentLanguage: state.currentLanguage), context: self)
case let .toggleShowWebsiteIcons(isOn):
state.isShowWebsiteIconsToggleOn = isOn
Task {
await services.stateService.setShowWebIcons(isOn)
}
}
}
}
// MARK: - SelectLanguageDelegate
extension AppearanceProcessor: SelectLanguageDelegate {
/// Update the language selection.
func languageSelected(_ languageOption: LanguageOption) {
state.currentLanguage = languageOption
}
}

View File

@ -0,0 +1,94 @@
import XCTest
@testable import AuthenticatorShared
class AppearanceProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var stateService: MockStateService!
var subject: AppearanceProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
stateService = MockStateService()
let services = ServiceContainer.withMocks(
stateService: stateService
)
subject = AppearanceProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: services,
state: AppearanceState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
stateService = nil
subject = nil
}
// MARK: Tests
/// The delegate method `languageSelected` should update the language.
func test_languageSelected() {
XCTAssertEqual(subject.state.currentLanguage, .default)
subject.languageSelected(.custom(languageCode: "th"))
XCTAssertEqual(subject.state.currentLanguage, .custom(languageCode: "th"))
}
/// `perform(_:)` with `.loadData` sets the value in the state.
func test_perform_loadData() async {
XCTAssertEqual(subject.state.appTheme, .default)
stateService.appLanguage = .custom(languageCode: "de")
stateService.appTheme = .light
stateService.showWebIcons = false
await subject.perform(.loadData)
XCTAssertEqual(subject.state.currentLanguage, .custom(languageCode: "de"))
XCTAssertEqual(subject.state.appTheme, .light)
XCTAssertFalse(subject.state.isShowWebsiteIconsToggleOn)
}
/// `receive(_:)` with `.appThemeChanged` updates the theme.
func test_receive_appThemeChanged() {
subject.receive(.appThemeChanged(.dark))
XCTAssertEqual(subject.state.appTheme, .dark)
waitFor(stateService.appTheme == .dark)
subject.receive(.appThemeChanged(.light))
XCTAssertEqual(subject.state.appTheme, .light)
waitFor(stateService.appTheme == .light)
}
/// `receive(_:)` with `.languageTapped` navigates to the select language view.
func test_receive_languageTapped() async throws {
subject.state.currentLanguage = .custom(languageCode: "th")
subject.receive(.languageTapped)
XCTAssertEqual(coordinator.routes.last, .selectLanguage(currentLanguage: LanguageOption("th")))
}
/// `receive(_:)` with `.toggleShowWebsiteIcons` updates the value in the state and the cache.
func test_receive_toggleShowWebsiteIcons() {
XCTAssertFalse(subject.state.isShowWebsiteIconsToggleOn)
subject.receive(.toggleShowWebsiteIcons(true))
XCTAssertTrue(subject.state.isShowWebsiteIconsToggleOn)
waitFor(stateService.showWebIcons == true)
}
}

View File

@ -0,0 +1,14 @@
// MARK: - AppearanceState
/// An object that defines the current state of the `AppearanceView`.
///
struct AppearanceState {
/// The selected app theme.
var appTheme: AppTheme = .default
/// Whether or not the show website icons toggle is on.
var isShowWebsiteIconsToggleOn: Bool = false
/// The current language selection.
var currentLanguage: LanguageOption = .default
}

View File

@ -0,0 +1,95 @@
import SwiftUI
// MARK: - AppearanceView
/// A view for configuring appearance settings.
///
struct AppearanceView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<AppearanceState, AppearanceAction, AppearanceEffect>
// MARK: View
var body: some View {
VStack(spacing: 16) {
language
theme
}
.scrollView()
.navigationBar(title: Localizations.appearance, titleDisplayMode: .inline)
.task {
await store.perform(.loadData)
}
}
// MARK: Private views
/// The language picker view
private var language: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsListItem(
Localizations.language,
hasDivider: false
) {
store.send(.languageTapped)
} trailingContent: {
Text(store.state.currentLanguage.title)
}
.cornerRadius(10)
Text(Localizations.languageChangeRequiresAppRestart)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// The application's color theme picker view
private var theme: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsMenuField(
title: Localizations.theme,
options: AppTheme.allCases,
hasDivider: false,
selection: store.binding(
get: \.appTheme,
send: AppearanceAction.appThemeChanged
)
)
.cornerRadius(10)
.accessibilityIdentifier("ThemeChooser")
Text(Localizations.themeDescription)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// The show website icons toggle.
private var webSiteIconsToggle: some View {
VStack(alignment: .leading, spacing: 0) {
Toggle(isOn: store.binding(
get: \.isShowWebsiteIconsToggleOn,
send: AppearanceAction.toggleShowWebsiteIcons
)) {
Text(Localizations.showWebsiteIcons)
}
.toggleStyle(.bitwarden)
.styleGuide(.body)
.accessibilityIdentifier("ShowWebsiteIconsSwitch")
Text(Localizations.showWebsiteIconsDescription)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
.padding(.bottom, 12)
}
}
// MARK: Previews
#Preview {
AppearanceView(store: Store(processor: StateProcessor(state: AppearanceState())))
}

View File

@ -0,0 +1,58 @@
import SnapshotTesting
import XCTest
// MARK: - AppearanceViewTests
@testable import AuthenticatorShared
class AppearanceViewTests: AuthenticatorTestCase {
// MARK: Properties
var processor: MockProcessor<AppearanceState, AppearanceAction, AppearanceEffect>!
var subject: AppearanceView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: AppearanceState())
let store = Store(processor: processor)
subject = AppearanceView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Updating the value of the app theme sends the `.appThemeChanged()` action.
func test_appThemeChanged_updateValue() throws {
processor.state.appTheme = .light
let menuField = try subject.inspect().find(settingsMenuField: Localizations.theme)
try menuField.select(newValue: AppTheme.dark)
XCTAssertEqual(processor.dispatchedActions.last, .appThemeChanged(.dark))
}
/// Tapping the language button dispatches the `.languageTapped` action.
func test_languageButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.language)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .languageTapped)
}
// MARK: Snapshots
/// Tests the view renders correctly.
func test_viewRender() {
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}
}

View File

@ -0,0 +1,10 @@
// MARK: - SelectLanguageAction
/// Actions that can be processed by a `SelectLanguageProcessor`.
enum SelectLanguageAction: Equatable {
/// The cancel button was tapped.
case dismiss
/// A language was selected.
case languageTapped(LanguageOption)
}

View File

@ -0,0 +1,80 @@
// MARK: - SelectLanguageDelegate
/// The delegate for updating the parent view after a language has been selected.
protocol SelectLanguageDelegate: AnyObject {
/// A language has been selected.
func languageSelected(_ languageOption: LanguageOption)
}
// MARK: - SelectLanguageProcessor
/// The processor used to manage state and handle actions for the `SelectLanguageView`.
///
final class SelectLanguageProcessor: StateProcessor<SelectLanguageState, SelectLanguageAction, Void> {
// MARK: Types
typealias Services = HasStateService
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The delegate for handling the selection flow.
private weak var delegate: SelectLanguageDelegate?
/// The services used by the processor.
private let services: Services
// MARK: Initialization
/// Initializes a `SelectLanguageProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used for navigation.
/// - delegate: The delegate for handling the selection flow.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
delegate: SelectLanguageDelegate?,
services: Services,
state: SelectLanguageState
) {
self.coordinator = coordinator
self.delegate = delegate
self.services = services
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: SelectLanguageAction) {
switch action {
case .dismiss:
coordinator.navigate(to: .dismiss)
case let .languageTapped(languageOption):
changeLanguage(to: languageOption)
}
}
// MARK: Private Methods
/// Update the language and show the confirmation alert.
private func changeLanguage(to languageOption: LanguageOption) {
// Don't do anything if the user has selected the currently selected language.
guard languageOption != state.currentLanguage else { return }
// Save the value.
state.currentLanguage = languageOption
services.stateService.appLanguage = languageOption
delegate?.languageSelected(languageOption)
// Show the confirmation alert and close the view after the user clicks ok.
coordinator.showAlert(.languageChanged(to: languageOption.title) { [weak self] in
self?.coordinator.navigate(to: .dismiss)
})
}
}

View File

@ -0,0 +1,84 @@
import XCTest
@testable import AuthenticatorShared
// MARK: - SelectLanguageProcessorTests
class SelectLanguageProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var delegate: MockSelectLanguageDelegate!
var stateService: MockStateService!
var subject: SelectLanguageProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
delegate = MockSelectLanguageDelegate()
stateService = MockStateService()
let services = ServiceContainer.withMocks(
stateService: stateService
)
subject = SelectLanguageProcessor(
coordinator: coordinator.asAnyCoordinator(),
delegate: delegate,
services: services,
state: SelectLanguageState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
delegate = nil
stateService = nil
subject = nil
}
// MARK: Tests
/// `.receive(_:)` with `.dismiss` dismisses the view.
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `.receive(_:)` with `.languageTapped` with a new language saves the selection and shows
/// the confirmation alert.
func test_receive_languageTapped() async throws {
subject.receive(.languageTapped(.custom(languageCode: "th")))
XCTAssertEqual(subject.state.currentLanguage, .custom(languageCode: "th"))
XCTAssertEqual(stateService.appLanguage, .custom(languageCode: "th"))
XCTAssertEqual(delegate.selectedLanguage, .custom(languageCode: "th"))
XCTAssertEqual(coordinator.alertShown.last, .languageChanged(to: LanguageOption("th").title) {})
// Tapping the button on the alert should dismiss the view.
let action = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first)
await action.handler?(action, [])
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `.receive(_:)` with `.languageTapped` with the same language has no effect.
func test_receive_languageTapped_noChange() {
subject.receive(.languageTapped(.default))
XCTAssertTrue(coordinator.alertShown.isEmpty)
}
}
// MARK: - MockSelectLanguageDelegate
class MockSelectLanguageDelegate: SelectLanguageDelegate {
var selectedLanguage: LanguageOption?
func languageSelected(_ languageOption: LanguageOption) {
selectedLanguage = languageOption
}
}

View File

@ -0,0 +1,7 @@
// MARK: - SelectLanguageState
/// The state used to present the `SelectLanguageView`.
struct SelectLanguageState: Equatable {
/// The currently selected language.
var currentLanguage: LanguageOption = .default
}

View File

@ -0,0 +1,60 @@
import SwiftUI
// MARK: - SelectLanguageView
/// A view that shows a list of all the available languages to select from..
///
struct SelectLanguageView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<SelectLanguageState, SelectLanguageAction, Void>
// MARK: View
var body: some View {
VStack(spacing: 0) {
ForEach(LanguageOption.allCases) { languageOption in
languageOptionRow(languageOption)
}
}
.background(Asset.Colors.backgroundPrimary.swiftUIColor)
.cornerRadius(10)
.scrollView()
.navigationBar(title: Localizations.selectLanguage, titleDisplayMode: .inline)
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
}
// MARK: Private Views
/// Show a checkmark as the trailing image for the currently selected language.
@ViewBuilder
private func checkmarkView(_ languageOption: LanguageOption) -> some View {
if languageOption == store.state.currentLanguage {
Image(asset: Asset.Images.check)
.imageStyle(.rowIcon)
}
}
/// Construct the row for the language option.
private func languageOptionRow(_ languageOption: LanguageOption) -> some View {
SettingsListItem(
languageOption.title,
hasDivider: !languageOption.isLast
) {
store.send(.languageTapped(languageOption))
} trailingContent: {
checkmarkView(languageOption)
}
}
}
// MARK: - Previews
#Preview {
SelectLanguageView(store: Store(processor: StateProcessor(state: SelectLanguageState())))
}

View File

@ -0,0 +1,64 @@
import SnapshotTesting
import XCTest
// MARK: - SelectLanguageViewTests
@testable import AuthenticatorShared
class SelectLanguageViewTests: AuthenticatorTestCase {
// MARK: Properties
var processor: MockProcessor<SelectLanguageState, SelectLanguageAction, Void>!
var subject: SelectLanguageView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: SelectLanguageState())
let store = Store(processor: processor)
subject = SelectLanguageView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the cancel button dispatches the `.dismiss` action.
func test_cancelButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.cancel)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
}
/// Tapping a language button dispatches the `.languageTapped(_)` action.
func test_languageButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.defaultSystem)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .languageTapped(.default))
}
// MARK: Snapshots
/// Test that the default view renders correctly.
func test_snapshot_default() {
assertSnapshot(of: subject.navStackWrapped, as: .defaultPortrait)
}
/// Test that the default view renders correctly.
func test_snapshot_default_dark() {
assertSnapshot(of: subject.navStackWrapped, as: .defaultPortraitDark)
}
/// Test that the default view renders correctly.
func test_snapshot_default_large() {
assertSnapshot(of: subject.navStackWrapped, as: .tallPortraitAX5())
}
}

View File

@ -0,0 +1,9 @@
/// Actions that can be processed by a `SettingsProcessor`.
///
enum SettingsAction: Equatable {
/// The about button was pressed.
case aboutPressed
/// The appearance button was pressed.
case appearancePressed
}

View File

@ -0,0 +1,5 @@
// MARK: - SettingsEvent
/// An event to be handled by the SettingsCoordinator.
///
enum SettingsEvent: Equatable {}

View File

@ -0,0 +1,111 @@
import SwiftUI
// MARK: - SettingsListItem
/// A list item that appears across settings screens.
///
struct SettingsListItem<Content: View>: View {
// MARK: Properties
/// The accessibility ID for the list item.
let accessibilityIdentifier: String?
/// The action to perform when the list item is tapped.
let action: () -> Void
/// Whether or not the list item should have a divider on the bottom.
let hasDivider: Bool
/// The name of the list item.
let name: String
/// The accessibility ID for the list item name.
let nameAccessibilityID: String?
/// Content that appears on the trailing edge of the list item.
let trailingContent: () -> Content?
// MARK: View
var body: some View {
Button {
action()
} label: {
VStack(spacing: 0) {
HStack {
Text(name)
.styleGuide(.body)
.accessibilityIdentifier(nameAccessibilityID ?? "")
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.multilineTextAlignment(.leading)
.padding(.vertical, 19)
Spacer()
trailingContent()
.styleGuide(.body)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, 16)
if hasDivider {
Divider()
.padding(.leading, 16)
}
}
}
.accessibilityIdentifier(accessibilityIdentifier ?? "")
.background(Asset.Colors.backgroundTertiary.swiftUIColor)
}
// MARK: Initialization
/// Initializes a new `SettingsListItem`.
///
/// - Parameters:
/// - name: The name of the list item.
/// - hasDivider: Whether or not the list item should have a divider on the bottom.
/// - accessibilityIdentifier: The accessibility ID for the list item.
/// - nameAccessibilityID: The accessibility ID for the list item name.
/// - action: The action to perform when the list item is tapped.
/// - trailingContent: Content that appears on the trailing edge of the list item.
///
/// - Returns: The list item.
///
init(
_ name: String,
hasDivider: Bool = true,
accessibilityIdentifier: String? = nil,
nameAccessibilityID: String? = nil,
action: @escaping () -> Void,
@ViewBuilder trailingContent: @escaping () -> Content? = { EmptyView() }
) {
self.accessibilityIdentifier = accessibilityIdentifier
self.name = name
self.hasDivider = hasDivider
self.nameAccessibilityID = nameAccessibilityID
self.trailingContent = trailingContent
self.action = action
}
}
// MARK: Previews
#if DEBUG
#Preview {
ScrollView {
VStack(spacing: 0) {
SettingsListItem("Account Security") {} trailingContent: {
Text("Trailing content")
}
SettingsListItem("Account Security") {} trailingContent: {
Image(asset: Asset.Images.externalLink)
}
SettingsListItem("Account Security") {}
}
}
}
#endif

View File

@ -0,0 +1,41 @@
// MARK: - SettingsProcessor
/// The processor used to manage state and handle actions for the settings screen.
///
final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Void> {
// MARK: Types
typealias Services = HasErrorReporter
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
// MARK: Initialization
/// Creates a new `SettingsProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
state: SettingsState
) {
self.coordinator = coordinator
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: SettingsAction) {
switch action {
case .aboutPressed:
coordinator.navigate(to: .about)
case .appearancePressed:
coordinator.navigate(to: .appearance)
}
}
}

View File

@ -0,0 +1,73 @@
import XCTest
@testable import AuthenticatorShared
class SettingsProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var subject: SettingsProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
subject = SettingsProcessor(
coordinator: coordinator.asAnyCoordinator(),
state: SettingsState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
subject = nil
}
// MARK: Tests
/// Receiving `.aboutPressed` navigates to the about screen.
func test_receive_aboutPressed() {
subject.receive(.aboutPressed)
XCTAssertEqual(coordinator.routes.last, .about)
}
/// Receiving `.accountSecurityPressed` navigates to the account security screen.
func test_receive_accountSecurityPressed() {
subject.receive(.accountSecurityPressed)
XCTAssertEqual(coordinator.routes.last, .accountSecurity)
}
/// Receiving `.appearancePressed` navigates to the appearance screen.
func test_receive_appearancePressed() {
subject.receive(.appearancePressed)
XCTAssertEqual(coordinator.routes.last, .appearance)
}
/// Receiving `.autoFillPressed` navigates to the auto-fill screen.
func test_receive_autoFillPressed() {
subject.receive(.autoFillPressed)
XCTAssertEqual(coordinator.routes.last, .autoFill)
}
/// Receiving `.otherPressed` navigates to the other screen.
func test_receive_otherPressed() {
subject.receive(.otherPressed)
XCTAssertEqual(coordinator.routes.last, .other)
}
/// Receiving `.vaultPressed` navigates to the vault settings screen.
func test_receive_vaultPressed() {
subject.receive(.vaultPressed)
XCTAssertEqual(coordinator.routes.last, .vault)
}
}

View File

@ -0,0 +1,5 @@
/// An object that defines the current state of a `SettingsView`.
///
struct SettingsState: Equatable {
// MARK: Properties
}

View File

@ -0,0 +1,60 @@
import SwiftUI
// MARK: - SettingsView
/// A view containing the top-level list of settings.
///
struct SettingsView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<SettingsState, SettingsAction, Void>
// MARK: View
var body: some View {
settingsItems
.scrollView()
.navigationBar(title: Localizations.settings, titleDisplayMode: .large)
}
// MARK: Private views
/// The chevron shown in the settings list item.
private var chevron: some View {
Image(asset: Asset.Images.rightAngle)
.resizable()
.scaledFrame(width: 12, height: 12)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
/// The settings items.
private var settingsItems: some View {
VStack(spacing: 0) {
SettingsListItem(Localizations.appearance) {
store.send(.appearancePressed)
} trailingContent: {
chevron
}
.accessibilityIdentifier("AppearanceSettingsButton")
SettingsListItem(Localizations.about, hasDivider: false) {
store.send(.aboutPressed)
} trailingContent: {
chevron
}
.accessibilityIdentifier("AboutSettingsButton")
}
.cornerRadius(10)
}
}
// MARK: - Previews
#if DEBUG
#Preview {
NavigationView {
SettingsView(store: Store(processor: StateProcessor(state: SettingsState())))
}
}
#endif

View File

@ -0,0 +1,80 @@
import SnapshotTesting
import XCTest
// MARK: - SettingsViewTests
@testable import AuthenticatorShared
class SettingsViewTests: AuthenticatorTestCase {
// MARK: Properties
var processor: MockProcessor<SettingsState, SettingsAction, Void>!
var subject: SettingsView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: SettingsState())
let store = Store(processor: processor)
subject = SettingsView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the about button dispatches the `.aboutPressed` action.
func test_aboutButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.about)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .aboutPressed)
}
/// Tapping the accountSecurity button dispatches the `.accountSecurityPressed` action.
func test_accountSecurityButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.accountSecurity)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .accountSecurityPressed)
}
/// Tapping the appearance button dispatches the `.appearancePressed` action.
func test_appearanceButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.appearance)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .appearancePressed)
}
/// Tapping the autofill button dispatches the `.autoFillPressed` action.
func test_autofillButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.autofill)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .autoFillPressed)
}
/// Tapping the other button dispatches the `.otherPressed` action.
func test_otherButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.other)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .otherPressed)
}
/// Tapping the vault button dispatches the `.vaultPressed` action.
func test_vaultButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.vault)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .vaultPressed)
}
/// Tests the view renders correctly.
func test_viewRender() {
assertSnapshot(of: subject, as: .defaultPortrait)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -0,0 +1,131 @@
import BitwardenSdk
import SwiftUI
// MARK: - SettingsCoordinator
/// A coordinator that manages navigation in the settings tab.
///
final class SettingsCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
/// The module types required by this coordinator for creating child coordinators.
typealias Module = DefaultAppModule
typealias Services = HasErrorReporter
& HasPasteboardService
& HasStateService
& HasTimeProvider
// MARK: Private Properties
/// The module used to create child coordinators.
private let module: Module
/// The services used by this coordinator.
private let services: Services
// MARK: Properties
/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `SettingsCoordinator`.
///
/// - Parameters:
/// - delegate: The delegate for this coordinator, used to notify when the user logs out.
/// - module: The module used to create child coordinators.
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
module: Module,
services: Services,
stackNavigator: StackNavigator
) {
self.module = module
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
func handleEvent(_ event: SettingsEvent, context: AnyObject?) async {}
func navigate(to route: SettingsRoute, context: AnyObject?) {
switch route {
case .about:
showAbout()
case .appearance:
showAppearance()
case .dismiss:
stackNavigator?.dismiss()
case let .selectLanguage(currentLanguage: currentLanguage):
showSelectLanguage(currentLanguage: currentLanguage, delegate: context as? SelectLanguageDelegate)
case .settings:
showSettings()
}
}
func start() {
navigate(to: .settings)
}
// MARK: Private Methods
/// Shows the about screen.
///
private func showAbout() {
let processor = AboutProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: AboutState()
)
let view = AboutView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
viewController.navigationItem.largeTitleDisplayMode = .never
stackNavigator?.push(viewController, navigationTitle: Localizations.about)
}
/// Shows the appearance screen.
///
private func showAppearance() {
let processor = AppearanceProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: AppearanceState()
)
let view = AppearanceView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
viewController.navigationItem.largeTitleDisplayMode = .never
stackNavigator?.push(viewController, navigationTitle: Localizations.appearance)
}
/// Shows the select language screen.
///
private func showSelectLanguage(currentLanguage: LanguageOption, delegate: SelectLanguageDelegate?) {
let processor = SelectLanguageProcessor(
coordinator: asAnyCoordinator(),
delegate: delegate,
services: services,
state: SelectLanguageState(currentLanguage: currentLanguage)
)
let view = SelectLanguageView(store: Store(processor: processor))
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
}
/// Shows the settings screen.
///
private func showSettings() {
let processor = SettingsProcessor(
coordinator: asAnyCoordinator(),
state: SettingsState()
)
let view = SettingsView(store: Store(processor: processor))
stackNavigator?.push(view)
}
}

View File

@ -0,0 +1,29 @@
// MARK: - SettingsModule
/// An object that builds coordinators for the settings tab.
///
@MainActor
protocol SettingsModule {
/// Initializes a coordinator for navigating between `SettingsRoute`s.
///
/// - Parameters:
/// - delegate: A delegate of the `SettingsCoordinator`.
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `SettingsRoute`s.
///
func makeSettingsCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<SettingsRoute, SettingsEvent>
}
extension DefaultAppModule: SettingsModule {
func makeSettingsCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<SettingsRoute, SettingsEvent> {
SettingsCoordinator(
module: self,
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
}

View File

@ -0,0 +1,24 @@
import BitwardenSdk
import Foundation
/// A route to a specific screen in the settings tab.
///
public enum SettingsRoute: Equatable, Hashable {
/// A route to the about view.
case about
/// A route to the appearance screen.
case appearance
/// A route that dismisses the current view.
case dismiss
/// A route to view the select language view.
///
/// - Parameter currentLanguage: The currently selected language option.
///
case selectLanguage(currentLanguage: LanguageOption)
/// A route to the settings screen.
case settings
}

View File

@ -0,0 +1,107 @@
import SwiftUI
import UIKit
// MARK: - TabCoordinator
/// A coordinator that manages navigation in the tab interface.
///
final class TabCoordinator: Coordinator, HasTabNavigator {
// MARK: Types
/// The module types required by this coordinator for creating child coordinators.
typealias Module = ItemListModule
& SettingsModule
// MARK: Properties
/// The root navigator used to display this coordinator's interface.
weak var rootNavigator: (any RootNavigator)?
/// The tab navigator that is managed by this coordinator.
private(set) weak var tabNavigator: TabNavigator?
// MARK: Private Properties
/// The error reporter used by the tab coordinator.
private var errorReporter: ErrorReporter
/// The coordinator used to navigate to `ItemListRoute`s.
private var itemListCoordinator: AnyCoordinator<ItemListRoute, ItemListEvent>?
/// The module used to create child coordinators.
private let module: Module
/// A task to handle organization streams.
private var organizationStreamTask: Task<Void, Error>?
/// The coordinator used to navigate to `SettingsRoute`s.
private var settingsCoordinator: AnyCoordinator<SettingsRoute, SettingsEvent>?
// MARK: Initialization
/// Creates a new `TabCoordinator`.
///
/// - Parameters:
/// - errorReporter: The error reporter used by the tab coordinator.
/// - module: The module used to create child coordinators.
/// - rootNavigator: The root navigator used to display this coordinator's interface.
/// - settingsDelegate: A delegate of the `SettingsCoordinator`.
/// - tabNavigator: The tab navigator that is managed by this coordinator.
/// - vaultDelegate: A delegate of the `VaultCoordinator`.
/// - vaultRepository: A vault repository used to the vault tab title.
///
init(
errorReporter: ErrorReporter,
module: Module,
rootNavigator: RootNavigator,
tabNavigator: TabNavigator
) {
self.errorReporter = errorReporter
self.module = module
self.rootNavigator = rootNavigator
self.tabNavigator = tabNavigator
}
deinit {
organizationStreamTask?.cancel()
organizationStreamTask = nil
}
// MARK: Methods
func navigate(to route: TabRoute, context: AnyObject?) {
tabNavigator?.selectedIndex = route.index
switch route {
case let .itemList(itemListRoute):
itemListCoordinator?.navigate(to: itemListRoute, context: context)
case let .settings(settingsRoute):
settingsCoordinator?.navigate(to: settingsRoute, context: context)
}
}
func start() {
guard let rootNavigator, let tabNavigator else { return }
rootNavigator.show(child: tabNavigator)
let itemListNavigator = UINavigationController()
itemListNavigator.navigationBar.prefersLargeTitles = true
itemListCoordinator = module.makeItemListCoordinator(
stackNavigator: itemListNavigator
)
let settingsNavigator = UINavigationController()
settingsNavigator.navigationBar.prefersLargeTitles = true
let settingsCoordinator = module.makeSettingsCoordinator(
stackNavigator: settingsNavigator
)
settingsCoordinator.start()
self.settingsCoordinator = settingsCoordinator
let tabsAndNavigators: [TabRoute: Navigator] = [
.itemList(.list): itemListNavigator,
.settings(.settings): settingsNavigator,
]
tabNavigator.setNavigators(tabsAndNavigators)
}
}

View File

@ -0,0 +1,41 @@
import UIKit
// MARK: - TabModule
/// An object that builds coordinators for the tab interface.
///
protocol TabModule: AnyObject {
/// Initializes a coordinator for navigating to `TabRoute`s.
///
/// - Parameter:
/// - errorReporter: The error reporter used by the tab module.
/// - rootNavigator: The root navigator used to display this coordinator's interface.
/// - settingsDelegate: The delegate for the settings coordinator.
/// - tabNavigator: The navigator used by the coordinator to navigate between routes.
/// - vaultDelegate: The delegate for the vault coordinator.
/// - vaultRepository: The vault repository used by the tab module.
/// - Returns: A new coordinator that can navigate to any `TabRoute`.
///
func makeTabCoordinator(
errorReporter: ErrorReporter,
rootNavigator: RootNavigator,
tabNavigator: TabNavigator
) -> AnyCoordinator<TabRoute, Void>
}
// MARK: - AppModule
extension DefaultAppModule: TabModule {
func makeTabCoordinator(
errorReporter: ErrorReporter,
rootNavigator: RootNavigator,
tabNavigator: TabNavigator
) -> AnyCoordinator<TabRoute, Void> {
TabCoordinator(
errorReporter: errorReporter,
module: self,
rootNavigator: rootNavigator,
tabNavigator: tabNavigator
).asAnyCoordinator()
}
}

View File

@ -0,0 +1,26 @@
import UIKit
// MARK: - TabRepresentable
/// An object that can represent a tab in a tab navigator.
///
public protocol TabRepresentable {
// MARK: Properties
/// The unselected image for this tab.
var image: UIImage? { get }
/// The index for this tab.
var index: Int { get }
/// The selected image for this tab.
var selectedImage: UIImage? { get }
/// The title for this tab.
var title: String { get }
}
public extension TabRepresentable where Self: RawRepresentable, Self.RawValue == Int {
/// The index for this tab.
var index: Int { rawValue }
}

View File

@ -0,0 +1,45 @@
import UIKit
// MARK: - TabRoute
/// The enumeration of tabs displayed by the application.
///
public enum TabRoute: Equatable, Hashable {
/// The verification codes
case itemList(ItemListRoute)
/// The settings tab.
case settings(SettingsRoute)
}
// MARK: - TabRepresentable
extension TabRoute: TabRepresentable {
public var image: UIImage? {
switch self {
case .itemList: return Asset.Images.lockedFilled.image
case .settings: return Asset.Images.gearFilled.image
}
}
public var index: Int {
switch self {
case .itemList: return 0
case .settings: return 1
}
}
public var selectedImage: UIImage? {
switch self {
case .itemList: return Asset.Images.lockedFilled.image
case .settings: return Asset.Images.gearFilled.image
}
}
public var title: String {
switch self {
case .itemList: return Localizations.verificationCodes
case .settings: return Localizations.settings
}
}
}

View File

@ -4,7 +4,7 @@ import Foundation
// MARK: - ItemListRoute
/// A route to a specific screen or subscreen of the Item List
enum ItemListRoute: Equatable {
public enum ItemListRoute: Equatable, Hashable {
/// A route to the add item screen.
case addItem