mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 23:33:36 -06:00
BIT-1325: Theme picker (#286)
This commit is contained in:
parent
fbe034678e
commit
31c08b3e3b
@ -35,7 +35,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
let appWindow = UIWindow(windowScene: windowScene)
|
||||
let rootViewController = RootViewController()
|
||||
appProcessor.start(appContext: .mainApp, navigator: rootViewController)
|
||||
appProcessor.start(
|
||||
appContext: .mainApp,
|
||||
navigator: rootViewController,
|
||||
window: appWindow
|
||||
)
|
||||
|
||||
appWindow.rootViewController = rootViewController
|
||||
appWindow.makeKeyAndVisible()
|
||||
|
||||
@ -6,6 +6,9 @@ import BitwardenShared
|
||||
class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
// MARK: Properties
|
||||
|
||||
/// The app's theme.
|
||||
var appTheme: AppTheme = .default
|
||||
|
||||
/// The processor that manages application level logic.
|
||||
private var appProcessor: AppProcessor?
|
||||
|
||||
@ -66,7 +69,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
let appProcessor = AppProcessor(appModule: appModule, services: services)
|
||||
self.appProcessor = appProcessor
|
||||
|
||||
appProcessor.start(appContext: .appExtension, navigator: self)
|
||||
appProcessor.start(appContext: .appExtension, navigator: self, window: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
88
BitwardenShared/Core/Platform/Models/Enum/AppTheme.swift
Normal file
88
BitwardenShared/Core/Platform/Models/Enum/AppTheme.swift
Normal file
@ -0,0 +1,88 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - AppTheme
|
||||
|
||||
/// An enum listing the display theme options.
|
||||
///
|
||||
public enum AppTheme: String, Menuable {
|
||||
/// Use the dark theme.
|
||||
case dark
|
||||
|
||||
/// Use the system settings.
|
||||
case `default`
|
||||
|
||||
/// Use the light theme.
|
||||
case light
|
||||
|
||||
// MARK: Type Properties
|
||||
|
||||
/// The ordered list of options to display in the menu.
|
||||
static let allCases: [AppTheme] = [.default, .light, .dark]
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Specify the text for the default option.
|
||||
static var defaultValueLocalizedName: String { Localizations.defaultSystem }
|
||||
|
||||
/// The name of the type to display in the dropdown menu.
|
||||
var localizedName: String {
|
||||
switch self {
|
||||
case .dark:
|
||||
Localizations.dark
|
||||
case .default:
|
||||
Localizations.defaultSystem
|
||||
case .light:
|
||||
Localizations.light
|
||||
}
|
||||
}
|
||||
|
||||
/// The color theme to set the status bar to.
|
||||
var statusBarStyle: UIStatusBarStyle {
|
||||
switch self {
|
||||
case .dark:
|
||||
.lightContent
|
||||
case .default:
|
||||
.default
|
||||
case .light:
|
||||
.darkContent
|
||||
}
|
||||
}
|
||||
|
||||
/// The value to use to actually set the app's theme.
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .dark:
|
||||
.dark
|
||||
case .default:
|
||||
.unspecified
|
||||
case .light:
|
||||
.light
|
||||
}
|
||||
}
|
||||
|
||||
/// The value to save to the local storage.
|
||||
var value: String? {
|
||||
switch self {
|
||||
case .dark:
|
||||
"dark"
|
||||
case .default:
|
||||
nil
|
||||
case .light:
|
||||
"light"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `ThemeOption`.`
|
||||
///
|
||||
/// - Parameter appTheme: The raw value string of the custom selection, or `nil` for default.
|
||||
///
|
||||
init(_ appTheme: String?) {
|
||||
if let appTheme {
|
||||
self = .init(rawValue: appTheme) ?? .default
|
||||
} else {
|
||||
self = .default
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class AppThemeTests: BitwardenTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// `init` returns the expected values.
|
||||
func test_init() {
|
||||
XCTAssertEqual(AppTheme("dark"), .dark)
|
||||
XCTAssertEqual(AppTheme(nil), .default)
|
||||
XCTAssertEqual(AppTheme("light"), .light)
|
||||
XCTAssertEqual(AppTheme("gibberish"), .default)
|
||||
}
|
||||
|
||||
/// `localizedName` has the expected values.
|
||||
func test_localizedName() {
|
||||
XCTAssertEqual(AppTheme.dark.localizedName, Localizations.dark)
|
||||
XCTAssertEqual(AppTheme.default.localizedName, Localizations.defaultSystem)
|
||||
XCTAssertEqual(AppTheme.light.localizedName, Localizations.light)
|
||||
}
|
||||
|
||||
/// `statusBarStyle` has the expected values.
|
||||
func test_statusBarStyle() {
|
||||
XCTAssertEqual(AppTheme.dark.statusBarStyle, .lightContent)
|
||||
XCTAssertEqual(AppTheme.default.statusBarStyle, .default)
|
||||
XCTAssertEqual(AppTheme.light.statusBarStyle, .darkContent)
|
||||
}
|
||||
|
||||
/// `userInterfaceStyle` has the expected values.
|
||||
func test_userInterfaceStyle() {
|
||||
XCTAssertEqual(AppTheme.dark.userInterfaceStyle, .dark)
|
||||
XCTAssertEqual(AppTheme.default.userInterfaceStyle, .unspecified)
|
||||
XCTAssertEqual(AppTheme.light.userInterfaceStyle, .light)
|
||||
}
|
||||
|
||||
/// `value` has the expected values.
|
||||
func test_value() {
|
||||
XCTAssertEqual(AppTheme.dark.value, "dark")
|
||||
XCTAssertNil(AppTheme.default.value)
|
||||
XCTAssertEqual(AppTheme.light.value, "light")
|
||||
}
|
||||
}
|
||||
@ -59,6 +59,12 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool
|
||||
|
||||
/// 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
|
||||
@ -130,6 +136,12 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool, userId: String?) async throws
|
||||
|
||||
/// Sets the app theme.
|
||||
///
|
||||
/// - Parameter appTheme: The new app theme.
|
||||
///
|
||||
func setAppTheme(_ appTheme: AppTheme) async
|
||||
|
||||
/// Sets the clear clipboard value for an account.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -193,6 +205,12 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func activeAccountIdPublisher() async -> AsyncPublisher<AnyPublisher<String?, Never>>
|
||||
|
||||
/// A publisher for the app theme.
|
||||
///
|
||||
/// - Returns: A publisher for the app theme.
|
||||
///
|
||||
func appThemePublisher() async -> AnyPublisher<AppTheme, Never>
|
||||
|
||||
/// A publisher for the last sync time for the active account.
|
||||
///
|
||||
/// - Returns: A publisher for the last sync time.
|
||||
@ -355,14 +373,19 @@ actor DefaultStateService: StateService {
|
||||
set { appSettingsStore.rememberedOrgIdentifier = newValue }
|
||||
}
|
||||
|
||||
// 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.
|
||||
let dataStore: DataStore
|
||||
private let dataStore: DataStore
|
||||
|
||||
/// A subject containing the last sync time mapped to user ID.
|
||||
var lastSyncTimeByUserIdSubject = CurrentValueSubject<[String: Date], Never>([:])
|
||||
private var lastSyncTimeByUserIdSubject = CurrentValueSubject<[String: Date], Never>([:])
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
@ -375,6 +398,7 @@ actor DefaultStateService: StateService {
|
||||
init(appSettingsStore: AppSettingsStore, dataStore: DataStore) {
|
||||
self.appSettingsStore = appSettingsStore
|
||||
self.dataStore = dataStore
|
||||
appThemeSubject = CurrentValueSubject(AppTheme(appSettingsStore.appTheme))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
@ -440,6 +464,10 @@ actor DefaultStateService: StateService {
|
||||
return appSettingsStore.allowSyncOnRefresh(userId: userId)
|
||||
}
|
||||
|
||||
func getAppTheme() async -> AppTheme {
|
||||
AppTheme(appSettingsStore.appTheme)
|
||||
}
|
||||
|
||||
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
return appSettingsStore.clearClipboardValue(userId: userId)
|
||||
@ -509,6 +537,11 @@ actor DefaultStateService: StateService {
|
||||
appSettingsStore.setAllowSyncOnRefresh(allowSyncOnRefresh, userId: userId)
|
||||
}
|
||||
|
||||
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)
|
||||
@ -559,6 +592,10 @@ actor DefaultStateService: StateService {
|
||||
appSettingsStore.activeAccountIdPublisher()
|
||||
}
|
||||
|
||||
func appThemePublisher() async -> AnyPublisher<AppTheme, Never> {
|
||||
appThemeSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func lastSyncTimePublisher() async throws -> AnyPublisher<Date?, Never> {
|
||||
let userId = try getActiveAccountUserId()
|
||||
if lastSyncTimeByUserIdSubject.value[userId] == nil {
|
||||
|
||||
@ -58,6 +58,32 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
|
||||
XCTAssertEqual(state.activeUserId, "2")
|
||||
}
|
||||
|
||||
/// `appTheme` gets and sets the value as expected.
|
||||
func test_appTheme() async {
|
||||
// Getting the value should get the value from the app settings store.
|
||||
appSettingsStore.appTheme = "light"
|
||||
let theme = await subject.getAppTheme()
|
||||
XCTAssertEqual(theme, .light)
|
||||
|
||||
// Setting the value should update the value in the app settings store.
|
||||
await subject.setAppTheme(.dark)
|
||||
XCTAssertEqual(appSettingsStore.appTheme, "dark")
|
||||
}
|
||||
|
||||
/// `appThemePublisher()` returns a publisher for the app's theme.
|
||||
func test_appThemePublisher() async {
|
||||
var publishedValues = [AppTheme]()
|
||||
let publisher = await subject.appThemePublisher()
|
||||
.sink(receiveValue: { date in
|
||||
publishedValues.append(date)
|
||||
})
|
||||
defer { publisher.cancel() }
|
||||
|
||||
await subject.setAppTheme(.dark)
|
||||
|
||||
XCTAssertEqual(publishedValues, [.default, .dark])
|
||||
}
|
||||
|
||||
/// `.deleteAccount()` deletes the active user's account, removing it from the state.
|
||||
func test_deleteAccount() async throws {
|
||||
let newAccount = Account.fixture(profile: Account.AccountProfile.fixture(userId: "1"))
|
||||
@ -588,7 +614,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
|
||||
)
|
||||
}
|
||||
|
||||
/// `setActiveAccount(userId: )` returns without aciton if there are no accounts
|
||||
/// `setActiveAccount(userId: )` returns without action if there are no accounts
|
||||
func test_setActiveAccount_noAccounts() async throws {
|
||||
let storeState = await subject.appSettingsStore.state
|
||||
XCTAssertNil(storeState)
|
||||
|
||||
@ -12,6 +12,9 @@ protocol AppSettingsStore: AnyObject {
|
||||
/// The app's unique identifier.
|
||||
var appId: String? { get set }
|
||||
|
||||
/// The app's theme.
|
||||
var appTheme: String? { get set }
|
||||
|
||||
/// The environment URLs used prior to user authentication.
|
||||
var preAuthEnvironmentUrls: EnvironmentUrlData? { get set }
|
||||
|
||||
@ -266,6 +269,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
enum Keys {
|
||||
case allowSyncOnRefresh(userId: String)
|
||||
case appId
|
||||
case appTheme
|
||||
case clearClipboardValue(userId: String)
|
||||
case encryptedPrivateKey(userId: String)
|
||||
case encryptedUserKey(userId: String)
|
||||
@ -286,6 +290,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
key = "syncOnRefresh_\(userId)"
|
||||
case .appId:
|
||||
key = "appId"
|
||||
case .appTheme:
|
||||
key = "theme"
|
||||
case let .clearClipboardValue(userId):
|
||||
key = "clearClipboard_\(userId)"
|
||||
case let .encryptedUserKey(userId):
|
||||
@ -318,6 +324,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
set { store(newValue, for: .appId) }
|
||||
}
|
||||
|
||||
var appTheme: String? {
|
||||
get { fetch(for: .appTheme) }
|
||||
set { store(newValue, for: .appTheme) }
|
||||
}
|
||||
|
||||
var preAuthEnvironmentUrls: EnvironmentUrlData? {
|
||||
get { fetch(for: .preAuthEnvironmentUrls) }
|
||||
set { store(newValue, for: .preAuthEnvironmentUrls) }
|
||||
|
||||
@ -4,6 +4,8 @@ import XCTest
|
||||
|
||||
// MARK: - AppSettingsStoreTests
|
||||
|
||||
// swiftlint:disable file_length
|
||||
|
||||
class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
|
||||
// MARK: Properties
|
||||
|
||||
@ -72,6 +74,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:syncOnRefresh_w"))
|
||||
}
|
||||
|
||||
/// `appTheme` returns `nil` if there isn't a previously stored value.
|
||||
func test_appTheme_isInitiallyNil() {
|
||||
XCTAssertNil(subject.appTheme)
|
||||
}
|
||||
|
||||
/// `appTheme` can be used to get and set the persisted value in user defaults.
|
||||
func test_appTheme_withValue() {
|
||||
subject.appTheme = "light"
|
||||
XCTAssertEqual(subject.appTheme, "light")
|
||||
XCTAssertEqual(userDefaults.string(forKey: "bwPreferencesStorage:theme"), "light")
|
||||
|
||||
subject.appTheme = nil
|
||||
XCTAssertNil(subject.appTheme)
|
||||
XCTAssertNil(userDefaults.string(forKey: "bwPreferencesStorage:theme"))
|
||||
}
|
||||
|
||||
/// `clearClipboardValue(userId:)` returns `.never` if there isn't a previously stored value.
|
||||
func test_clearClipboardValue_isInitiallyNil() {
|
||||
XCTAssertEqual(subject.clearClipboardValue(userId: "0"), .never)
|
||||
|
||||
@ -6,6 +6,7 @@ import Foundation
|
||||
class MockAppSettingsStore: AppSettingsStore {
|
||||
var allowSyncOnRefreshes = [String: Bool]()
|
||||
var appId: String?
|
||||
var appTheme: String?
|
||||
var clearClipboardValues = [String: ClearClipboardValue]()
|
||||
var encryptedPrivateKeys = [String: String]()
|
||||
var encryptedUserKeys = [String: String]()
|
||||
|
||||
@ -11,6 +11,7 @@ class MockStateService: StateService {
|
||||
var activeAccount: Account?
|
||||
var accounts: [Account]?
|
||||
var allowSyncOnRefresh = [String: Bool]()
|
||||
var appTheme: AppTheme?
|
||||
var clearClipboardValues = [String: ClearClipboardValue]()
|
||||
var clearClipboardResult: Result<Void, Error> = .success(())
|
||||
var environmentUrls = [String: EnvironmentUrlData]()
|
||||
@ -23,6 +24,7 @@ class MockStateService: StateService {
|
||||
var usernameGenerationOptions = [String: UsernameGenerationOptions]()
|
||||
|
||||
lazy var activeIdSubject = CurrentValueSubject<String?, Never>(self.activeAccount?.profile.userId)
|
||||
lazy var appThemeSubject = CurrentValueSubject<AppTheme, Never>(self.appTheme ?? .default)
|
||||
|
||||
func addAccount(_ account: BitwardenShared.Account) async {
|
||||
accountsAdded.append(account)
|
||||
@ -72,6 +74,10 @@ class MockStateService: StateService {
|
||||
try getActiveAccount().profile.userId
|
||||
}
|
||||
|
||||
func getAppTheme() async -> AppTheme {
|
||||
appTheme ?? .default
|
||||
}
|
||||
|
||||
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool {
|
||||
let userId = try userId ?? getActiveAccount().profile.userId
|
||||
return allowSyncOnRefresh[userId] ?? false
|
||||
@ -130,6 +136,10 @@ class MockStateService: StateService {
|
||||
self.allowSyncOnRefresh[userId] = allowSyncOnRefresh
|
||||
}
|
||||
|
||||
func setAppTheme(_ appTheme: AppTheme) async {
|
||||
self.appTheme = appTheme
|
||||
}
|
||||
|
||||
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws {
|
||||
try clearClipboardResult.get()
|
||||
let userId = try userId ?? getActiveAccount().profile.userId
|
||||
@ -170,9 +180,11 @@ class MockStateService: StateService {
|
||||
}
|
||||
|
||||
func activeAccountIdPublisher() async -> AsyncPublisher<AnyPublisher<String?, Never>> {
|
||||
activeIdSubject
|
||||
.eraseToAnyPublisher()
|
||||
.values
|
||||
activeIdSubject.eraseToAnyPublisher().values
|
||||
}
|
||||
|
||||
func appThemePublisher() async -> AnyPublisher<AppTheme, Never> {
|
||||
appThemeSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func lastSyncTimePublisher() async throws -> AnyPublisher<Date?, Never> {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// The `AppProcessor` processes actions received at the application level and contains the logic
|
||||
/// to control the top-level flow through the app.
|
||||
@ -41,12 +41,19 @@ public class AppProcessor {
|
||||
/// - Parameters:
|
||||
/// - appContext: The context that the app is running within.
|
||||
/// - navigator: The object that will be used to navigate between routes.
|
||||
/// - window: The window to use to set the app's theme.
|
||||
///
|
||||
public func start(appContext: AppContext, navigator: RootNavigator) {
|
||||
public func start(appContext: AppContext, navigator: RootNavigator, window: UIWindow?) {
|
||||
let coordinator = appModule.makeAppCoordinator(appContext: appContext, navigator: navigator)
|
||||
coordinator.start()
|
||||
self.coordinator = coordinator
|
||||
|
||||
Task {
|
||||
for await appTheme in await services.stateService.appThemePublisher().values {
|
||||
navigator.appTheme = appTheme
|
||||
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await services.environmentService.loadURLsForActiveAccount()
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
|
||||
let rootNavigator = MockRootNavigator()
|
||||
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator)
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
|
||||
|
||||
XCTAssertTrue(appModule.appCoordinator.isStarted)
|
||||
XCTAssertEqual(appModule.appCoordinator.routes, [.auth(.vaultUnlock(.fixture()))])
|
||||
@ -60,7 +60,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
func test_start_noActiveAccount() {
|
||||
let rootNavigator = MockRootNavigator()
|
||||
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator)
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
|
||||
|
||||
XCTAssertTrue(appModule.appCoordinator.isStarted)
|
||||
XCTAssertEqual(appModule.appCoordinator.routes, [.auth(.landing)])
|
||||
@ -71,7 +71,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
func test_start_shouldClearData() {
|
||||
let rootNavigator = MockRootNavigator()
|
||||
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator)
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
|
||||
|
||||
vaultTimeoutService.shouldClearSubject.send(true)
|
||||
|
||||
@ -84,7 +84,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
func test_start_shouldNotClearData() {
|
||||
let rootNavigator = MockRootNavigator()
|
||||
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator)
|
||||
subject.start(appContext: .mainApp, navigator: rootNavigator, window: nil)
|
||||
|
||||
vaultTimeoutService.shouldClearSubject.send(false)
|
||||
|
||||
|
||||
@ -6,6 +6,9 @@ import UIKit
|
||||
///
|
||||
@MainActor
|
||||
public protocol RootNavigator: Navigator {
|
||||
/// The app's theme.
|
||||
var appTheme: AppTheme { get set }
|
||||
|
||||
/// Shows the specified child navigator.
|
||||
///
|
||||
/// - Parameter child: The navigator to show.
|
||||
@ -15,6 +18,10 @@ public protocol RootNavigator: Navigator {
|
||||
// MARK: - RootViewController
|
||||
|
||||
extension RootViewController: RootNavigator {
|
||||
override public var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
appTheme.statusBarStyle
|
||||
}
|
||||
|
||||
public var rootViewController: UIViewController? {
|
||||
self
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ import UIKit
|
||||
/// controller.
|
||||
///
|
||||
public class RootViewController: UIViewController {
|
||||
/// The app's theme.
|
||||
public var appTheme: AppTheme = .default
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The child view controller currently being displayed within this root view controller.
|
||||
|
||||
@ -48,6 +48,18 @@ class RootViewControllerTests: BitwardenTestCase {
|
||||
XCTAssertTrue(subject.view.subviews.isEmpty)
|
||||
}
|
||||
|
||||
/// `preferredStatusBarStyle` returns the preferred status bar style for the given theme.
|
||||
func test_preferredStatusBarStyle() {
|
||||
subject.appTheme = .dark
|
||||
XCTAssertEqual(subject.preferredStatusBarStyle, .lightContent)
|
||||
|
||||
subject.appTheme = .default
|
||||
XCTAssertEqual(subject.preferredStatusBarStyle, .default)
|
||||
|
||||
subject.appTheme = .light
|
||||
XCTAssertEqual(subject.preferredStatusBarStyle, .darkContent)
|
||||
}
|
||||
|
||||
/// `rootViewController` returns itself, instead of the current `childViewController`.
|
||||
func test_rootViewController() {
|
||||
let viewController = UIViewController()
|
||||
|
||||
@ -126,8 +126,37 @@ extension StackNavigator {
|
||||
/// - Parameters:
|
||||
/// - viewController: The view controller to push onto the stack.
|
||||
/// - animated: Whether the transition should be animated. Defaults to `UI.animated`.
|
||||
/// - navigationTitle: The navigation title to pre-populate the navigation bar so that it doesn't flash.
|
||||
/// - hasSearchBar: Whether or not to pre-populate the navigation bar with a search bar.
|
||||
///
|
||||
func push(_ viewController: UIViewController, animated: Bool = UI.animated) {
|
||||
func push(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool = UI.animated,
|
||||
navigationTitle: String? = nil,
|
||||
hasSearchBar: Bool = false
|
||||
) {
|
||||
if let navigationTitle {
|
||||
// Preset some navigation item values so that the navigation bar does not flash oddly once
|
||||
// the view's push animation has completed. This happens because `UIHostingController` does
|
||||
// not resolve its `navigationItem` properties until the view has been displayed on screen.
|
||||
// In this case, that doesn't happen until the push animation has completed, which results
|
||||
// in both the title and the search bar flashing into view after the push animation
|
||||
// completes. This occurs on all iOS versions (tested on iOS 17).
|
||||
//
|
||||
// The values set here are temporary, and are overwritten once the hosting controller has
|
||||
// resolved its root view's navigation bar modifiers.
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
viewController.navigationItem.title = navigationTitle
|
||||
if hasSearchBar {
|
||||
let searchController = UISearchController()
|
||||
if #available(iOS 16.0, *) {
|
||||
viewController.navigationItem.preferredSearchBarPlacement = .stacked
|
||||
}
|
||||
viewController.navigationItem.searchController = searchController
|
||||
viewController.navigationItem.hidesSearchBarWhenScrolling = false
|
||||
}
|
||||
}
|
||||
|
||||
push(viewController, animated: animated)
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,8 @@
|
||||
/// Actions handled by the `AppearanceProcessor`.
|
||||
///
|
||||
enum AppearanceAction: Equatable {
|
||||
/// The default dark mode theme was changed.
|
||||
case defaultDarkThemeChanged
|
||||
|
||||
/// The default color theme was changed.
|
||||
case defaultThemeChanged
|
||||
case appThemeChanged(AppTheme)
|
||||
|
||||
/// The language option was tapped.
|
||||
case languageTapped
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,37 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - AppearanceProcessor
|
||||
|
||||
/// The processor used to manage state and handle actions for the `AppearanceView`.
|
||||
///
|
||||
final class AppearanceProcessor: StateProcessor<AppearanceState, AppearanceAction, Void> {
|
||||
final class AppearanceProcessor: StateProcessor<AppearanceState, AppearanceAction, AppearanceEffect> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasStateService
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Coordinator` that handles navigation.
|
||||
private let coordinator: AnyCoordinator<SettingsRoute>
|
||||
|
||||
/// 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>,
|
||||
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.appTheme = await services.stateService.getAppTheme()
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: AppearanceAction) {
|
||||
switch action {
|
||||
case .defaultDarkThemeChanged:
|
||||
print("languageTapped")
|
||||
case .defaultThemeChanged:
|
||||
print("languageTapped")
|
||||
case let .appThemeChanged(appTheme):
|
||||
state.appTheme = appTheme
|
||||
Task {
|
||||
await services.stateService.setAppTheme(appTheme)
|
||||
}
|
||||
case .languageTapped:
|
||||
print("languageTapped")
|
||||
case let .toggleShowWebsiteIcons(isOn):
|
||||
|
||||
@ -6,6 +6,7 @@ class AppearanceProcessorTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var coordinator: MockCoordinator<SettingsRoute>!
|
||||
var stateService: MockStateService!
|
||||
var subject: AppearanceProcessor!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
@ -14,8 +15,14 @@ class AppearanceProcessorTests: BitwardenTestCase {
|
||||
super.setUp()
|
||||
|
||||
coordinator = MockCoordinator()
|
||||
stateService = MockStateService()
|
||||
let services = ServiceContainer.withMocks(
|
||||
stateService: stateService
|
||||
)
|
||||
|
||||
subject = AppearanceProcessor(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
services: services,
|
||||
state: AppearanceState()
|
||||
)
|
||||
}
|
||||
@ -24,13 +31,37 @@ class AppearanceProcessorTests: BitwardenTestCase {
|
||||
super.tearDown()
|
||||
|
||||
coordinator = nil
|
||||
stateService = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `perform(_:)` with `.loadData` sets the app's theme.
|
||||
func test_perform_loadData() async {
|
||||
XCTAssertEqual(subject.state.appTheme, .default)
|
||||
stateService.appTheme = .light
|
||||
|
||||
await subject.perform(.loadData)
|
||||
|
||||
XCTAssertEqual(subject.state.appTheme, .light)
|
||||
}
|
||||
|
||||
/// `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 `.toggleShowWebsiteIcons` updates the state's value.
|
||||
func test_toggleShowWebsiteIcons() {
|
||||
func test_receive_toggleShowWebsiteIcons() {
|
||||
XCTAssertFalse(subject.state.isShowWebsiteIconsToggleOn)
|
||||
|
||||
subject.receive(.toggleShowWebsiteIcons(true))
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
/// 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
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ struct AppearanceView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The store used to render the view.
|
||||
@ObservedObject var store: Store<AppearanceState, AppearanceAction, Void>
|
||||
@ObservedObject var store: Store<AppearanceState, AppearanceAction, AppearanceEffect>
|
||||
|
||||
// MARK: View
|
||||
|
||||
@ -18,12 +18,13 @@ struct AppearanceView: View {
|
||||
|
||||
theme
|
||||
|
||||
defaultDarkTheme
|
||||
|
||||
webSiteIconsToggle
|
||||
}
|
||||
.scrollView()
|
||||
.navigationBar(title: Localizations.appearance, titleDisplayMode: .inline)
|
||||
.task {
|
||||
await store.perform(.loadData)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private views
|
||||
@ -50,14 +51,15 @@ struct AppearanceView: View {
|
||||
/// The application's color theme picker view
|
||||
private var theme: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsListItem(
|
||||
Localizations.theme,
|
||||
hasDivider: false
|
||||
) {
|
||||
store.send(.defaultThemeChanged)
|
||||
} trailingContent: {
|
||||
Text(Localizations.defaultSystem)
|
||||
}
|
||||
SettingsMenuField(
|
||||
title: Localizations.theme,
|
||||
options: AppTheme.allCases,
|
||||
hasDivider: false,
|
||||
selection: store.binding(
|
||||
get: \.appTheme,
|
||||
send: AppearanceAction.appThemeChanged
|
||||
)
|
||||
)
|
||||
.cornerRadius(10)
|
||||
|
||||
Text(Localizations.themeDescription)
|
||||
@ -66,25 +68,6 @@ struct AppearanceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default dark mode color theme picker view
|
||||
private var defaultDarkTheme: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SettingsListItem(
|
||||
Localizations.defaultDarkTheme,
|
||||
hasDivider: false
|
||||
) {
|
||||
store.send(.defaultDarkThemeChanged)
|
||||
} trailingContent: {
|
||||
Text(Localizations.dark)
|
||||
}
|
||||
.cornerRadius(10)
|
||||
|
||||
Text(Localizations.defaultDarkThemeDescriptionLong)
|
||||
.styleGuide(.subheadline)
|
||||
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
|
||||
}
|
||||
}
|
||||
|
||||
/// The show website icons toggle.
|
||||
private var webSiteIconsToggle: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@ -8,7 +8,7 @@ import XCTest
|
||||
class AppearanceViewTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<AppearanceState, AppearanceAction, Void>!
|
||||
var processor: MockProcessor<AppearanceState, AppearanceAction, AppearanceEffect>!
|
||||
var subject: AppearanceView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
@ -31,18 +31,12 @@ class AppearanceViewTests: BitwardenTestCase {
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the language button dispatches the `.defaultDarkTheme` action.
|
||||
func test_defaultDarkThemeButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.defaultDarkTheme)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .defaultDarkThemeChanged)
|
||||
}
|
||||
|
||||
/// Tapping the language button dispatches the `.defaultThemeChanged` action.
|
||||
func test_defaultThemeButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.theme)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .defaultThemeChanged)
|
||||
/// 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.
|
||||
@ -52,6 +46,8 @@ class AppearanceViewTests: BitwardenTestCase {
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .languageTapped)
|
||||
}
|
||||
|
||||
// MARK: Snapshots
|
||||
|
||||
/// Tests the view renders correctly.
|
||||
func test_viewRender() {
|
||||
assertSnapshots(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
@ -137,7 +137,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = AboutView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.about)
|
||||
}
|
||||
|
||||
/// Shows the account security screen.
|
||||
@ -152,7 +152,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = AccountSecurityView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.accountSecurity)
|
||||
}
|
||||
|
||||
/// Shows the add or edit folder screen.
|
||||
@ -175,12 +175,16 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
/// Shows the appearance screen.
|
||||
///
|
||||
private func showAppearance() {
|
||||
let processor = AppearanceProcessor(coordinator: asAnyCoordinator(), state: AppearanceState())
|
||||
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)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.appearance)
|
||||
}
|
||||
|
||||
/// Shows the app extension screen.
|
||||
@ -193,7 +197,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = AppExtensionView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.appExtension)
|
||||
}
|
||||
|
||||
/// Shows the auto-fill screen.
|
||||
@ -206,7 +210,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = AutoFillView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.autofill)
|
||||
}
|
||||
|
||||
/// Shows the delete account screen.
|
||||
@ -245,7 +249,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = FoldersView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.folders)
|
||||
}
|
||||
|
||||
/// Shows the other settings screen.
|
||||
@ -260,7 +264,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = OtherSettingsView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.other)
|
||||
}
|
||||
|
||||
/// Shows the password auto-fill screen.
|
||||
@ -269,7 +273,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = PasswordAutoFillView()
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.passwordAutofill)
|
||||
}
|
||||
|
||||
/// Shows the settings screen.
|
||||
@ -290,6 +294,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = VaultSettingsView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(viewController, navigationTitle: Localizations.vault)
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,6 +82,15 @@ class SettingsCoordinatorTests: BitwardenTestCase {
|
||||
XCTAssertTrue(action.view is UIHostingController<AppearanceView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.appExtension` pushes the app extension view onto the stack navigator.
|
||||
func test_navigateTo_appExtension() throws {
|
||||
subject.navigate(to: .appExtension)
|
||||
|
||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
||||
XCTAssertEqual(action.type, .pushed)
|
||||
XCTAssertTrue(action.view is UIHostingController<AppExtensionView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.autoFill` pushes the auto-fill view onto the stack navigator.
|
||||
func test_navigateTo_autoFill() throws {
|
||||
subject.navigate(to: .autoFill)
|
||||
|
||||
@ -136,25 +136,11 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
|
||||
let view = VaultGroupView(store: store)
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
|
||||
// Preset some navigation item values so that the navigation bar does not flash oddly once
|
||||
// the view's push animation has completed. This happens because `UIHostingController` does
|
||||
// not resolve its `navigationItem` properties until the view has been displayed on screen.
|
||||
// In this case, that doesn't happen until the push animation has completed, which results
|
||||
// in both the title and the search bar flashing into view after the push animation
|
||||
// completes. This occurs on all iOS versions (tested on iOS 17).
|
||||
//
|
||||
// The values set here are temporary, and are overwritten once the hosting controller has
|
||||
// resolved its root view's navigation bar modifiers.
|
||||
viewController.navigationItem.largeTitleDisplayMode = .never
|
||||
viewController.navigationItem.title = group.navigationTitle
|
||||
let searchController = UISearchController()
|
||||
if #available(iOS 16.0, *) {
|
||||
viewController.navigationItem.preferredSearchBarPlacement = .stacked
|
||||
}
|
||||
viewController.navigationItem.searchController = searchController
|
||||
viewController.navigationItem.hidesSearchBarWhenScrolling = false
|
||||
|
||||
stackNavigator.push(viewController)
|
||||
stackNavigator.push(
|
||||
viewController,
|
||||
navigationTitle: group.navigationTitle,
|
||||
hasSearchBar: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows the vault list screen.
|
||||
|
||||
@ -24,6 +24,9 @@ struct BitwardenTextFieldType: BaseViewType {
|
||||
]
|
||||
}
|
||||
|
||||
/// A generic type wrapper around ` BitwardenMenuFieldType` to allow `ViewInspector` to find instances of
|
||||
/// ` BitwardenMenuFieldType` without needing to know the details of it's implementation.
|
||||
///
|
||||
struct BitwardenMenuFieldType: BaseViewType {
|
||||
static var typePrefix: String = "BitwardenMenuField"
|
||||
|
||||
@ -32,6 +35,17 @@ struct BitwardenMenuFieldType: BaseViewType {
|
||||
]
|
||||
}
|
||||
|
||||
/// A generic type wrapper around `SettingsMenuField` to allow `ViewInspector` to find instances of
|
||||
/// `SettingsMenuField` without needing to know the details of it's implementation.
|
||||
///
|
||||
struct SettingsMenuFieldType: BaseViewType {
|
||||
static var typePrefix: String = "SettingsMenuField"
|
||||
|
||||
static var namespacedPrefixes: [String] = [
|
||||
"BitwardenShared.SettingsMenuField",
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: InspectableView
|
||||
|
||||
extension InspectableView {
|
||||
@ -47,7 +61,7 @@ extension InspectableView {
|
||||
///
|
||||
func find(
|
||||
asyncButton title: String,
|
||||
locale: Locale = .testsDefault
|
||||
locale _: Locale = .testsDefault
|
||||
) throws -> InspectableView<AsyncButtonType> {
|
||||
try find(AsyncButtonType.self, containing: title)
|
||||
}
|
||||
@ -175,6 +189,21 @@ extension InspectableView {
|
||||
try find(ViewType.SecureField.self, containing: label)
|
||||
}
|
||||
|
||||
/// Attempts to locate a settings menu field with the provided title.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: The title to use while searching for a menu field.
|
||||
/// - locale: The locale for text extraction.
|
||||
/// - Returns: A `SettingsMenuField`, if one can be located.
|
||||
/// - Throws: Throws an error if a view was unable to be located.
|
||||
///
|
||||
func find(
|
||||
settingsMenuField title: String,
|
||||
locale: Locale = .testsDefault
|
||||
) throws -> InspectableView<SettingsMenuFieldType> {
|
||||
try find(SettingsMenuFieldType.self, containing: title, locale: locale)
|
||||
}
|
||||
|
||||
/// Attempts to locate a slider with the provided accessibility label.
|
||||
///
|
||||
/// - Parameter accessibilityLabel: The accessibility label to use while searching for a slider.
|
||||
@ -248,3 +277,12 @@ extension InspectableView where View == BitwardenMenuFieldType {
|
||||
try picker.select(value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension InspectableView where View == SettingsMenuFieldType {
|
||||
/// Selects a new value in the menu field.
|
||||
///
|
||||
func select(newValue: any Hashable) throws {
|
||||
let picker = try find(ViewType.Picker.self)
|
||||
try picker.select(value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import BitwardenShared
|
||||
import UIKit
|
||||
|
||||
final class MockRootNavigator: RootNavigator {
|
||||
var appTheme: AppTheme = .default
|
||||
var navigatorShown: Navigator?
|
||||
var rootViewController: UIViewController?
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user