Create settings view (#25)
@ -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 {}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
162
AuthenticatorShared/Core/Platform/Services/StateService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/")!
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]?) {}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
@ -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-")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "check.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/check.imageset/check.pdf
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))"
|
||||
}
|
||||
@ -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())))
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 193 KiB |
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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())))
|
||||
}
|
||||
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
// MARK: - SelectLanguageState
|
||||
|
||||
/// The state used to present the `SelectLanguageView`.
|
||||
struct SelectLanguageState: Equatable {
|
||||
/// The currently selected language.
|
||||
var currentLanguage: LanguageOption = .default
|
||||
}
|
||||
@ -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())))
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 209 KiB |
@ -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
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
// MARK: - SettingsEvent
|
||||
|
||||
/// An event to be handled by the SettingsCoordinator.
|
||||
///
|
||||
enum SettingsEvent: Equatable {}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
/// An object that defines the current state of a `SettingsView`.
|
||||
///
|
||||
struct SettingsState: Equatable {
|
||||
// MARK: Properties
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 94 KiB |
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
24
AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift
Normal 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
|
||||
}
|
||||
107
AuthenticatorShared/UI/Platform/Tabs/TabCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
41
AuthenticatorShared/UI/Platform/Tabs/TabModule.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
26
AuthenticatorShared/UI/Platform/Tabs/TabRepresentable.swift
Normal 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 }
|
||||
}
|
||||
45
AuthenticatorShared/UI/Platform/Tabs/TabRoute.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||