diff --git a/Bitwarden/Application/SceneDelegate.swift b/Bitwarden/Application/SceneDelegate.swift index 9f49ec9b1..6198e61f7 100644 --- a/Bitwarden/Application/SceneDelegate.swift +++ b/Bitwarden/Application/SceneDelegate.swift @@ -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() diff --git a/BitwardenAutoFillExtension/CredentialProviderViewController.swift b/BitwardenAutoFillExtension/CredentialProviderViewController.swift index be12e2a5a..2de124de9 100644 --- a/BitwardenAutoFillExtension/CredentialProviderViewController.swift +++ b/BitwardenAutoFillExtension/CredentialProviderViewController.swift @@ -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) } } diff --git a/BitwardenShared/Core/Platform/Models/Enum/AppTheme.swift b/BitwardenShared/Core/Platform/Models/Enum/AppTheme.swift new file mode 100644 index 000000000..8b01118cc --- /dev/null +++ b/BitwardenShared/Core/Platform/Models/Enum/AppTheme.swift @@ -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 + } + } +} diff --git a/BitwardenShared/Core/Platform/Models/Enum/AppThemeTests.swift b/BitwardenShared/Core/Platform/Models/Enum/AppThemeTests.swift new file mode 100644 index 000000000..e6db7403b --- /dev/null +++ b/BitwardenShared/Core/Platform/Models/Enum/AppThemeTests.swift @@ -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") + } +} diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 90d625a64..bf9029bde 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -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> + /// A publisher for the app theme. + /// + /// - Returns: A publisher for the app theme. + /// + func appThemePublisher() async -> AnyPublisher + /// 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 + /// 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 { + appThemeSubject.eraseToAnyPublisher() + } + func lastSyncTimePublisher() async throws -> AnyPublisher { let userId = try getActiveAccountUserId() if lastSyncTimeByUserIdSubject.value[userId] == nil { diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index b686392b4..675e89f9a 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -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) diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 2b03364fc..814969d41 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -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) } diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index 7a6087877..2d92d450e 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -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) diff --git a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index bdd3f1fba..bc5a604d6 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -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]() diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index d09b54a81..20292648e 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -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 = .success(()) var environmentUrls = [String: EnvironmentUrlData]() @@ -23,6 +24,7 @@ class MockStateService: StateService { var usernameGenerationOptions = [String: UsernameGenerationOptions]() lazy var activeIdSubject = CurrentValueSubject(self.activeAccount?.profile.userId) + lazy var appThemeSubject = CurrentValueSubject(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> { - activeIdSubject - .eraseToAnyPublisher() - .values + activeIdSubject.eraseToAnyPublisher().values + } + + func appThemePublisher() async -> AnyPublisher { + appThemeSubject.eraseToAnyPublisher() } func lastSyncTimePublisher() async throws -> AnyPublisher { diff --git a/BitwardenShared/UI/Platform/Application/AppProcessor.swift b/BitwardenShared/UI/Platform/Application/AppProcessor.swift index b26493d1f..8fd0a0678 100644 --- a/BitwardenShared/UI/Platform/Application/AppProcessor.swift +++ b/BitwardenShared/UI/Platform/Application/AppProcessor.swift @@ -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() } diff --git a/BitwardenShared/UI/Platform/Application/AppProcessorTests.swift b/BitwardenShared/UI/Platform/Application/AppProcessorTests.swift index d03ba4d14..df3809793 100644 --- a/BitwardenShared/UI/Platform/Application/AppProcessorTests.swift +++ b/BitwardenShared/UI/Platform/Application/AppProcessorTests.swift @@ -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) diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift index d4fc12f09..23c389fea 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift @@ -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 } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift index 014914959..e8460c386 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift @@ -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. diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift index 20e3377ae..7924a7957 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift @@ -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() diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift index 8705e1285..c319ab9e0 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift @@ -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) } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceAction.swift index 0e5bdd9b5..92ce15abd 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceAction.swift @@ -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 diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceEffect.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceEffect.swift new file mode 100644 index 000000000..acadc1fa0 --- /dev/null +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceEffect.swift @@ -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 +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessor.swift index 092123bef..7f1e848ca 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessor.swift @@ -1,37 +1,58 @@ +import Foundation + // MARK: - AppearanceProcessor /// The processor used to manage state and handle actions for the `AppearanceView`. /// -final class AppearanceProcessor: StateProcessor { +final class AppearanceProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasStateService + // MARK: Properties /// The `Coordinator` that handles navigation. private let coordinator: AnyCoordinator + /// 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, + 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): diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessorTests.swift index 118682379..8b9613dfd 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceProcessorTests.swift @@ -6,6 +6,7 @@ class AppearanceProcessorTests: BitwardenTestCase { // MARK: Properties var coordinator: MockCoordinator! + 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)) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceState.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceState.swift index 07587e9b3..a89b42cba 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceState.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceState.swift @@ -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 } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceView.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceView.swift index 9dfb640c6..ae71d1cca 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceView.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceView.swift @@ -8,7 +8,7 @@ struct AppearanceView: View { // MARK: Properties /// The store used to render the view. - @ObservedObject var store: Store + @ObservedObject var store: Store // 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) { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceViewTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceViewTests.swift index 804e642ba..c24904f3b 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceViewTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/AppearanceViewTests.swift @@ -8,7 +8,7 @@ import XCTest class AppearanceViewTests: BitwardenTestCase { // MARK: Properties - var processor: MockProcessor! + var processor: MockProcessor! 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( diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.1.png b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.1.png index 9f71d0c76..9d466f40a 100644 Binary files a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.1.png and b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.1.png differ diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.2.png b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.2.png index ebe946d40..3e348da3b 100644 Binary files a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.2.png and b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.2.png differ diff --git a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.3.png b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.3.png index cb0e04665..06ed249dc 100644 Binary files a/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.3.png and b/BitwardenShared/UI/Platform/Settings/Settings/Appearance/__Snapshots__/AppearanceViewTests/test_viewRender.3.png differ diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift index 6c9b86830..8fc5d7c67 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -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) } } diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift index fb7aa7071..519e253c8 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift @@ -82,6 +82,15 @@ class SettingsCoordinatorTests: BitwardenTestCase { XCTAssertTrue(action.view is UIHostingController) } + /// `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) + } + /// `navigate(to:)` with `.autoFill` pushes the auto-fill view onto the stack navigator. func test_navigateTo_autoFill() throws { subject.navigate(to: .autoFill) diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift index 9470c8287..c4c86c4ba 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift @@ -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. diff --git a/GlobalTestHelpers/Extensions/InspectableView.swift b/GlobalTestHelpers/Extensions/InspectableView.swift index cfc6768d1..91bc68890 100644 --- a/GlobalTestHelpers/Extensions/InspectableView.swift +++ b/GlobalTestHelpers/Extensions/InspectableView.swift @@ -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 { 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 { + 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) + } +} diff --git a/GlobalTestHelpers/MockRootNavigator.swift b/GlobalTestHelpers/MockRootNavigator.swift index 7722a094f..422061dc8 100644 --- a/GlobalTestHelpers/MockRootNavigator.swift +++ b/GlobalTestHelpers/MockRootNavigator.swift @@ -2,6 +2,7 @@ import BitwardenShared import UIKit final class MockRootNavigator: RootNavigator { + var appTheme: AppTheme = .default var navigatorShown: Navigator? var rootViewController: UIViewController?