BIT-1325: Theme picker (#286)

This commit is contained in:
Shannon Draeker 2024-01-10 10:49:15 -07:00 committed by GitHub
parent fbe034678e
commit 31c08b3e3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 476 additions and 98 deletions

View File

@ -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()

View File

@ -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)
}
}

View 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
}
}
}

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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) }

View File

@ -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)

View File

@ -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]()

View File

@ -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> {

View File

@ -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()
}

View File

@ -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)

View File

@ -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
}

View File

@ -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.

View File

@ -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()

View File

@ -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)
}

View File

@ -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

View File

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

View File

@ -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):

View File

@ -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))

View File

@ -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
}

View File

@ -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) {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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.

View File

@ -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)
}
}

View File

@ -2,6 +2,7 @@ import BitwardenShared
import UIKit
final class MockRootNavigator: RootNavigator {
var appTheme: AppTheme = .default
var navigatorShown: Navigator?
var rootViewController: UIViewController?