diff --git a/.swiftlint.yml b/.swiftlint.yml index 84a6c426c..26b1e391f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -71,3 +71,7 @@ type_contents_order: identifier_name: excluded: - id + +inclusive_language: + override_allowed_terms: + - masterPassword diff --git a/BitwardenShared/Core/Auth/Models/API/CreateAccount/CreateAccountRequestModel.swift b/BitwardenShared/Core/Auth/Models/API/CreateAccount/CreateAccountRequestModel.swift index 18f61dc92..a14dba7df 100644 --- a/BitwardenShared/Core/Auth/Models/API/CreateAccount/CreateAccountRequestModel.swift +++ b/BitwardenShared/Core/Auth/Models/API/CreateAccount/CreateAccountRequestModel.swift @@ -33,10 +33,10 @@ struct CreateAccountRequestModel: Equatable { let keys: KeysRequestModel? = nil /// The master password hash used to authenticate a user. - let masterPasswordHash: String // swiftlint:disable:this inclusive_language + let masterPasswordHash: String /// The master password hint. - let masterPasswordHint: String? = nil // swiftlint:disable:this inclusive_language + let masterPasswordHint: String? = nil /// The user's name. let name: String? = nil diff --git a/BitwardenShared/UI/Auth/AuthCoordinator.swift b/BitwardenShared/UI/Auth/AuthCoordinator.swift index 215e6fb32..5e4743e4e 100644 --- a/BitwardenShared/UI/Auth/AuthCoordinator.swift +++ b/BitwardenShared/UI/Auth/AuthCoordinator.swift @@ -36,10 +36,24 @@ internal final class AuthCoordinator: Coordinator { switch route { case .createAccount: showCreateAccount() + case .enterpriseSingleSignOn: + showEnterpriseSingleSignOn() case .landing: showLanding() - case .login: - showLogin() + case let .login(username, region, isLoginWithDeviceVisible): + showLogin( + state: LoginState( + isLoginWithDeviceVisible: isLoginWithDeviceVisible, + username: username, + region: region + ) + ) + case .loginOptions: + showLoginOptions() + case .loginWithDevice: + showLoginWithDevice() + case .masterPasswordHint: + showMasterPasswordHint() case .regionSelection: showRegionSelection() } @@ -61,29 +75,63 @@ internal final class AuthCoordinator: Coordinator { ) ) ) - stackNavigator.push(view, animated: UI.animated) + stackNavigator.push(view) + } + + /// Shows the enterprise single sign-on screen. + private func showEnterpriseSingleSignOn() { + let view = Text("Enterprise Single Sign-On") + stackNavigator.push(view) } /// Shows the landing screen. private func showLanding() { - let processor = LandingProcessor( + if stackNavigator.popToRoot(animated: UI.animated).isEmpty { + let processor = LandingProcessor( + coordinator: asAnyCoordinator(), + state: LandingState() + ) + let store = Store(processor: processor) + let view = LandingView(store: store) + stackNavigator.push(view) + } + } + + /// Shows the login screen. + /// + /// - Parameter state: The `LoginState` to initialize the login screen with. + /// + private func showLogin(state: LoginState) { + let processor = LoginProcessor( coordinator: asAnyCoordinator(), - state: LandingState() + state: state ) let store = Store(processor: processor) - let view = LandingView(store: store) - stackNavigator.push(view, animated: UI.animated) + let view = LoginView(store: store) + stackNavigator.push(view) } - /// Shows the login screen. - private func showLogin() { - let view = Text("Login") - stackNavigator.push(view, animated: UI.animated) + /// Shows the login options screen. + private func showLoginOptions() { + let view = Text("Login Options") + stackNavigator.push(view) + } + + /// Shows the login with device screen. + private func showLoginWithDevice() { + let view = Text("Login With Device") + stackNavigator.push(view) + } + + /// Shows the master password hint screen. + private func showMasterPasswordHint() { + let view = Text("Master Password Hint") + stackNavigator.push(view) } /// Shows the region selection screen. private func showRegionSelection() { let view = Text("Region") - stackNavigator.push(view, animated: UI.animated) + stackNavigator.push(view) } } diff --git a/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift index ba64849ac..73b37da83 100644 --- a/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift +++ b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift @@ -41,17 +41,60 @@ class AuthCoordinatorTests: BitwardenTestCase { XCTAssertTrue(stackNavigator.actions.last?.view is CreateAccountView) } + /// `navigate(to:)` with `.enterpriseSingleSignOn` pushes the enterprise single sign-on view onto the stack + /// navigator. + func test_navigate_enterpriseSingleSignOn() { + subject.navigate(to: .enterpriseSingleSignOn) + XCTAssertTrue(stackNavigator.actions.last?.view is Text) + } + /// `navigate(to:)` with `.landing` pushes the landing view onto the stack navigator. func test_navigate_landing() { subject.navigate(to: .landing) XCTAssertTrue(stackNavigator.actions.last?.view is LandingView) } + /// `navigate(to:)` with `.landing` from `.login` pops back to the landing view. + func test_navigate_landing_fromLogin() { + stackNavigator.viewControllersToPop = [ + UIViewController(), + ] + subject.navigate(to: .landing) + + XCTAssertEqual(stackNavigator.actions.last?.type, .poppedToRoot) + } + /// `navigate(to:)` with `.login` pushes the login view onto the stack navigator. - func test_navigate_login() { - subject.navigate(to: .login) + func test_navigate_login() throws { + subject.navigate(to: .login( + username: "username", + region: "region", + isLoginWithDeviceVisible: true + )) + + XCTAssertEqual(stackNavigator.actions.last?.type, .pushed) + let view = try XCTUnwrap(stackNavigator.actions.last?.view as? LoginView) + let state = view.store.state + XCTAssertEqual(state.username, "username") + XCTAssertEqual(state.region, "region") + XCTAssertTrue(state.isLoginWithDeviceVisible) + } + + /// `navigate(to:)` with `.loginOptions` pushes the login options view onto the stack navigator. + func test_navigate_loginOptions() { + subject.navigate(to: .loginOptions) + XCTAssertTrue(stackNavigator.actions.last?.view is Text) + } + + /// `navigate(to:)` with `.loginWithDevice` pushes the login with device view onto the stack navigator. + func test_navigate_loginWithDevice() { + subject.navigate(to: .loginWithDevice) + XCTAssertTrue(stackNavigator.actions.last?.view is Text) + } - // Placeholder assertion until the login screen is added: BIT-83 + /// `navigate(to:)` with `.masterPasswordHint` pushes the master password hint view onto the stack navigator. + func test_navigate_masterPasswordHint() { + subject.navigate(to: .masterPasswordHint) XCTAssertTrue(stackNavigator.actions.last?.view is Text) } diff --git a/BitwardenShared/UI/Auth/AuthRoute.swift b/BitwardenShared/UI/Auth/AuthRoute.swift index 121442bd4..790258118 100644 --- a/BitwardenShared/UI/Auth/AuthRoute.swift +++ b/BitwardenShared/UI/Auth/AuthRoute.swift @@ -5,11 +5,29 @@ public enum AuthRoute: Equatable { /// A route to the create account screen. case createAccount + /// A route to the enterprise single sign-on screen. + case enterpriseSingleSignOn + /// A route to the landing screen. case landing /// A route to the login screen. - case login + /// + /// - Parameters: + /// - username: The username to display on the login screen. + /// - region: The region the user has selected for login. + /// - isLoginWithDeviceVisible: A flag indicating if the "Login with device" button should be displayed in the + /// login screen. + case login(username: String, region: String, isLoginWithDeviceVisible: Bool) + + /// A route to the login options screen. + case loginOptions + + /// A route to the login with device screen. + case loginWithDevice + + /// A route to the master password hint screen. + case masterPasswordHint /// A route to the region selection screen. case regionSelection diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift index 82c52344a..84098654f 100644 --- a/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift @@ -28,7 +28,12 @@ class LandingProcessor: StateProcessor { override func receive(_ action: LandingAction) { switch action { case .continuePressed: - coordinator.navigate(to: .login) + // Region placeholder until region selection support is added: BIT-268 + coordinator.navigate(to: .login( + username: state.email, + region: "region", + isLoginWithDeviceVisible: false + )) case .createAccountPressed: coordinator.navigate(to: .createAccount) case let .emailChanged(newValue): diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift index dc7c10257..dc9ff411b 100644 --- a/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift @@ -33,8 +33,14 @@ class LandingProcessorTests: BitwardenTestCase { /// `receive(_:)` with `.continuePressed` navigates to the login screen. func test_receive_continuePressed() { + subject.state.email = "email@example.com" + subject.receive(.continuePressed) - XCTAssertEqual(coordinator.routes.last, .login) + XCTAssertEqual(coordinator.routes.last, .login( + username: "email@example.com", + region: "region", + isLoginWithDeviceVisible: false + )) } /// `receive(_:)` with `.createAccountPressed` navigates to the create account screen. diff --git a/BitwardenShared/UI/Auth/Login/LoginAction.swift b/BitwardenShared/UI/Auth/Login/LoginAction.swift new file mode 100644 index 000000000..45c8580fc --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginAction.swift @@ -0,0 +1,28 @@ +// MARK: - LoginAction + +/// Actions that can be processed by a `LoginProcessor`. +enum LoginAction: Equatable { + /// The get master password hint button was pressed. + case getMasterPasswordHintPressed + + /// The enterprise single sign-on button was pressed. + case enterpriseSingleSignOnPressed + + /// The login with device button was pressed. + case loginWithDevicePressed + + /// The login with master password button was pressed. + case loginWithMasterPasswordPressed + + /// The value for the master password was changed. + case masterPasswordChanged(String) + + /// The more button was pressed. + case morePressed + + /// The not you? button was pressed. + case notYouPressed + + /// The reveal master password field button was pressed. + case revealMasterPasswordFieldPressed +} diff --git a/BitwardenShared/UI/Auth/Login/LoginProcessor.swift b/BitwardenShared/UI/Auth/Login/LoginProcessor.swift new file mode 100644 index 000000000..8db9e2f11 --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginProcessor.swift @@ -0,0 +1,47 @@ +// MARK: LoginProcessor + +/// The processor used to manage state and handle actions for the login screen. +/// +class LoginProcessor: StateProcessor { + // MARK: Private Properties + + /// The `Coordinator` that handles navigation. + private var coordinator: AnyCoordinator + + // MARK: Initialization + + /// Creates a new `LoginProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - state: The initial state of the processor. + /// + init(coordinator: AnyCoordinator, state: LoginState) { + self.coordinator = coordinator + super.init(state: state) + } + + // MARK: Methods + + override func receive(_ action: LoginAction) { + switch action { + case .enterpriseSingleSignOnPressed: + coordinator.navigate(to: .enterpriseSingleSignOn) + case .getMasterPasswordHintPressed: + coordinator.navigate(to: .masterPasswordHint) + case .loginWithDevicePressed: + coordinator.navigate(to: .loginWithDevice) + case .loginWithMasterPasswordPressed: + // Add login functionality here: BIT-132 + print("login with master password") + case let .masterPasswordChanged(newValue): + state.masterPassword = newValue + case .morePressed: + coordinator.navigate(to: .loginOptions) + case .notYouPressed: + coordinator.navigate(to: .landing) + case .revealMasterPasswordFieldPressed: + state.isMasterPasswordRevealed.toggle() + } + } +} diff --git a/BitwardenShared/UI/Auth/Login/LoginProcessorTests.swift b/BitwardenShared/UI/Auth/Login/LoginProcessorTests.swift new file mode 100644 index 000000000..bc4a3d0ab --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginProcessorTests.swift @@ -0,0 +1,87 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - LoginProcessorTests + +class LoginProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var subject: LoginProcessor! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + coordinator = MockCoordinator() + subject = LoginProcessor( + coordinator: coordinator.asAnyCoordinator(), + state: LoginState() + ) + } + + override func tearDown() { + super.tearDown() + coordinator = nil + subject = nil + } + + // MARK: Tests + + /// `receive(_:)` with `.enterpriseSingleSignOnPressed` navigates to the enterprise single sign-on screen. + func test_receive_enterpriseSingleSignOnPressed() { + subject.receive(.enterpriseSingleSignOnPressed) + XCTAssertEqual(coordinator.routes.last, .enterpriseSingleSignOn) + } + + /// `receive(_:)` with `.getMasterPasswordHintPressed` navigates to the master password hint screen. + func test_receive_getMasterPasswordHintPressed() { + subject.receive(.getMasterPasswordHintPressed) + XCTAssertEqual(coordinator.routes.last, .masterPasswordHint) + } + + /// `receive(_:)` with `.loginWithDevicePressed` navigates to the login with device screen. + func test_receive_loginWithDevicePressed() { + subject.receive(.loginWithDevicePressed) + XCTAssertEqual(coordinator.routes.last, .loginWithDevice) + } + + /// `receive(_:)` with `.loginWithMasterPasswordPressed` logs the user in with the provided master password. + func test_receive_loginWithMasterPasswordPressed() { + subject.receive(.loginWithMasterPasswordPressed) + + // Temporary assertion until login functionality is added: BIT-132 + XCTAssertTrue(coordinator.routes.isEmpty) + } + + /// `receive(_:)` with `.masterPasswordChanged` updates the state to reflect the changes. + func test_receive_masterPasswordChanged() { + subject.state.masterPassword = "" + + subject.receive(.masterPasswordChanged("password")) + XCTAssertEqual(subject.state.masterPassword, "password") + } + + /// `receive(_:)` with `.morePressed` navigates to the login options screen. + func test_receive_morePressed() { + subject.receive(.morePressed) + XCTAssertEqual(coordinator.routes.last, .loginOptions) + } + + /// `receive(_:)` with `.notYouPressed` navigates to the landing screen. + func test_receive_notYouPressed() { + subject.receive(.notYouPressed) + XCTAssertEqual(coordinator.routes.last, .landing) + } + + /// `receive(_:)` with `.revealMasterPasswordFieldPressed` updates the state to reflect the changes. + func test_receive_revealMasterPasswordFieldPressed() { + subject.state.isMasterPasswordRevealed = false + subject.receive(.revealMasterPasswordFieldPressed) + XCTAssertTrue(subject.state.isMasterPasswordRevealed) + + subject.receive(.revealMasterPasswordFieldPressed) + XCTAssertFalse(subject.state.isMasterPasswordRevealed) + } +} diff --git a/BitwardenShared/UI/Auth/Login/LoginState.swift b/BitwardenShared/UI/Auth/Login/LoginState.swift new file mode 100644 index 000000000..aa0eb70ae --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginState.swift @@ -0,0 +1,22 @@ +// MARK: - LoginState + +/// An object that defines the current state of a `LoginView`. +/// +struct LoginState: Equatable { + // MARK: Properties + + /// The master password provided by the user. + var masterPassword: String = "" + + /// A flag indicating if the master password should be revealed or not. + var isMasterPasswordRevealed: Bool = false + + /// A flag indicating if the login with device button should be displayed or not. + var isLoginWithDeviceVisible: Bool = false + + /// The username provided by the user on the landing screen. + var username: String = "" + + /// The region selected by the user on the landing screen. + var region: String = "" +} diff --git a/BitwardenShared/UI/Auth/Login/LoginView.swift b/BitwardenShared/UI/Auth/Login/LoginView.swift new file mode 100644 index 000000000..954df7909 --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +// MARK: - LoginView + +/// A view that allows the user to input their master password to complete the +/// login flow, or allows the user to navigate to separate views for alternate +/// forms of login. +/// +struct LoginView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + BitwardenTextField( + title: Localizations.masterPassword, + icon: Image(asset: Asset.Images.eye), + contentType: .password, + text: store.binding( + get: { $0.masterPassword }, + send: { .masterPasswordChanged($0) } + ) + ) + + Button(Localizations.getMasterPasswordwordHint) { + store.send(.getMasterPasswordHintPressed) + } + .font(.system(.footnote)) + + Button { + store.send(.loginWithMasterPasswordPressed) + } label: { + Text(Localizations.logInWithMasterPassword) + .bold() + .foregroundColor(.white) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.blue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + if store.state.isLoginWithDeviceVisible { + Button { + store.send(.loginWithDevicePressed) + } label: { + Text(Localizations.logInWithDevice) + .foregroundColor(.gray) + .padding(12) + .frame(maxWidth: .infinity) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.gray) + } + } + } + + Button { + store.send(.enterpriseSingleSignOnPressed) + } label: { + Text(Localizations.logInSso) + .foregroundColor(.gray) + .padding(12) + .frame(maxWidth: .infinity) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.gray) + } + } + + Spacer() + .frame(height: 12) + + Text(Localizations.loggedInAsOn(store.state.username, store.state.region)) + Button(Localizations.notYou) { + store.send(.notYouPressed) + } + } + .padding(.horizontal) + .frame(maxWidth: .infinity) + .navigationTitle(Localizations.bitwarden) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.send(.morePressed) + } label: { + Label { + Text(Localizations.options) + } icon: { + Asset.Images.moreVert.swiftUIImage + } + } + } + } + } + } +} + +// MARK: - Previews + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoginView( + store: Store( + processor: StateProcessor( + state: LoginState() + ) + ) + ) + } + .previewDisplayName("Empty") + + NavigationView { + LoginView( + store: Store( + processor: StateProcessor( + state: LoginState( + isLoginWithDeviceVisible: true + ) + ) + ) + ) + } + .previewDisplayName("With Device") + } +} diff --git a/BitwardenShared/UI/Auth/Login/LoginViewTests.swift b/BitwardenShared/UI/Auth/Login/LoginViewTests.swift new file mode 100644 index 000000000..871c6ed15 --- /dev/null +++ b/BitwardenShared/UI/Auth/Login/LoginViewTests.swift @@ -0,0 +1,114 @@ +import SwiftUI +import ViewInspector +import XCTest + +@testable import BitwardenShared + +// MARK: - LoginViewTests + +class LoginViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: LoginView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + processor = MockProcessor(state: LoginState()) + let store = Store(processor: processor) + subject = LoginView(store: store) + } + + override func tearDown() { + super.tearDown() + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tapping the login button dispatches the `.loginWithMasterPasswordPressed` action. + func test_loginButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.logInWithMasterPassword) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .loginWithMasterPasswordPressed) + } + + /// Tapping the enterprise single sign-on button dispatches the `.enterpriseSingleSignOnPressed` action. + func test_enterpriseSingleSignOnButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.logInSso) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .enterpriseSingleSignOnPressed) + } + + /// Tapping the not you button dispatches the `.notYouPressed` action. + func test_notYouButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.notYou) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .notYouPressed) + } + + /// Tapping the get master password hint button dispatches the `.getMasterPasswordHintPressed` action. + func test_getMasterPasswordHintButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.getMasterPasswordwordHint) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .getMasterPasswordHintPressed) + } + + /// Tapping the options button in the nav bar dispatches the `.morePressed` action. + func test_moreButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.options) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .morePressed) + } + + /// The login with device button should not be visible when `isLoginWithDeviceVisible` is `false`. + func test_loginWithDeviceButton_isLoginWithDeviceVisible_false() { + processor.state.isLoginWithDeviceVisible = false + XCTAssertThrowsError(try subject.inspect().find(button: Localizations.logInWithDevice)) + } + + /// The login with device button should be visible when `isLoginWithDeviceVisible` is `true`. + func test_loginWithDeviceButton_isLoginWithDeviceVisible_true() { + processor.state.isLoginWithDeviceVisible = true + XCTAssertNoThrow(try subject.inspect().find(button: Localizations.logInWithDevice)) + } + + /// Tapping the login with device button dispatches the `.loginWithDevicePressed` action. + func test_loginWithDeviceButton_tap() throws { + processor.state.isLoginWithDeviceVisible = true + let button = try subject.inspect().find(button: Localizations.logInWithDevice) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .loginWithDevicePressed) + } + + /// The secure field is visible when `isMasterPasswordRevealed` is `false`. + func test_isMasterPasswordRevealed_false() { + processor.state.isMasterPasswordRevealed = false + } + + /// The text field is visible when `isMasterPasswordRevealed` is `true`. + func test_isMasterPasswordRevealed_true() { + processor.state.isMasterPasswordRevealed = true + XCTAssertThrowsError(try subject.inspect().find(textField: "")) + XCTAssertNoThrow(try subject.inspect().find(secureField: "")) + } + + /// Updating the text field dispatches the `.masterPasswordChanged()` action. + func test_textField_updateValue() throws { + processor.state.isMasterPasswordRevealed = true + let textField = try subject.inspect().find(secureField: "") + try textField.setInput("text") + XCTAssertEqual(processor.dispatchedActions.last, .masterPasswordChanged("text")) + } + + /// Updating the secure field dispatches the `.masterPasswordChanged()` action. + func test_secureField_updateValue() throws { + processor.state.isMasterPasswordRevealed = false + let secureField = try subject.inspect().find(secureField: "") + try secureField.setInput("text") + XCTAssertEqual(processor.dispatchedActions.last, .masterPasswordChanged("text")) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift index 8bcd9f39d..35ec84076 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift @@ -3,11 +3,13 @@ import SwiftUI // MARK: - StackNavigator /// An object used to navigate between views in a stack interface. +/// @MainActor public protocol StackNavigator: Navigator { /// Dismisses the view that was presented modally by the navigator. /// /// - Parameter animated: Whether the transition should be animated. + /// func dismiss(animated: Bool) /// Pushes a view onto the navigator's stack. @@ -16,17 +18,24 @@ public protocol StackNavigator: Navigator { /// - view: The view to push onto the stack. /// - animated: Whether the transition should be animated. /// - hidesBottomBar: Whether the bottom bar should be hidden when the view is pushed. + /// func push(_ view: Content, animated: Bool, hidesBottomBar: Bool) /// Pops a view off the navigator's stack. /// /// - Parameter animated: Whether the transition should be animated. - func pop(animated: Bool) + /// - Returns: The `UIViewController` that was popped off the navigator's stack. + /// + @discardableResult + func pop(animated: Bool) -> UIViewController? /// Pops all the view controllers on the stack except the root view controller. /// /// - Parameter animated: Whether the transition should be animated. - func popToRoot(animated: Bool) + /// - Returns: An array of `UIViewController`s that were popped of the navigator's stack. + /// + @discardableResult + func popToRoot(animated: Bool) -> [UIViewController] /// Presents a view modally. /// @@ -34,6 +43,7 @@ public protocol StackNavigator: Navigator { /// - view: The view to present. /// - animated: Whether the transition should be animated. /// - overFullscreen: Whether or not the presented modal should cover the full screen. + /// func present(_ view: Content, animated: Bool, overFullscreen: Bool) /// Presents a view controller modally. Supports presenting on top of presented modals if necessary. @@ -41,6 +51,7 @@ public protocol StackNavigator: Navigator { /// - Parameters: /// - viewController: The view controller to present. /// - animated: Whether the transition should be animated. + /// func present(_ viewController: UIViewController, animated: Bool) /// Replaces the stack with the specified view. @@ -48,27 +59,72 @@ public protocol StackNavigator: Navigator { /// - Parameters: /// - view: The view that will replace the stack. /// - animated: Whether the transition should be animated. + /// func replace(_ view: Content, animated: Bool) } extension StackNavigator { + /// Dismisses the view that was presented modally by the navigator. Animation is controlled by `UI.animated`. + /// + func dismiss() { + dismiss(animated: UI.animated) + } + /// Pushes a view onto the navigator's stack. /// /// - Parameters: /// - view: The view to push onto the stack. - /// - animated: Whether the transition should be animated. - func push(_ view: Content, animated: Bool) { + /// - animated: Whether the transition should be animated. Defaults to `UI.animated`. + /// + func push(_ view: Content, animated: Bool = UI.animated) { push(view, animated: animated, hidesBottomBar: false) } + /// Pops a view off the navigator's stack. Animation is controlled by `UI.animated`. + /// + /// - Returns: The `UIViewController` that was popped off the navigator's stack. + /// + @discardableResult + func pop() -> UIViewController? { + pop(animated: UI.animated) + } + + /// Pops all the view controllers on the stack except the root view controller. Animation is controlled by + /// `UI.animated`. + /// + /// - Returns: An array of `UIViewController`s that were popped of the navigator's stack. + /// + @discardableResult + func popToRoot() -> [UIViewController] { + popToRoot(animated: UI.animated) + } + /// Presents a view modally. /// /// - Parameters: /// - view: The view to present. - /// - animated: Whether the transition should be animated. - func present(_ view: Content, animated: Bool) { + /// - animated: Whether the transition should be animated. Defaults to `UI.animated`. + /// + func present(_ view: Content, animated: Bool = UI.animated) { present(view, animated: animated, overFullscreen: false) } + + /// Presents a view controller modally. Supports presenting on top of presented modals if necessary. Animation is + /// controlled by `UI.animated`. + /// + /// - Parameter viewController: The view controller to present. + /// + func present(_ viewController: UIViewController) { + present(viewController, animated: UI.animated) + } + + /// Replaces the stack with the specified view. Animation is controlled by `UI.animated`. + /// + /// - Parameter view: The view that will replace the stack. + /// + func replace(_ view: Content) { + replace(view, animated: UI.animated) + } } // MARK: - UINavigationController @@ -82,12 +138,14 @@ extension UINavigationController: StackNavigator { dismiss(animated: animated, completion: nil) } - public func pop(animated: Bool) { + @discardableResult + public func pop(animated: Bool) -> UIViewController? { popViewController(animated: animated) } - public func popToRoot(animated: Bool) { - popToRootViewController(animated: animated) + @discardableResult + public func popToRoot(animated: Bool) -> [UIViewController] { + popToRootViewController(animated: animated) ?? [] } public func push(_ view: Content, animated: Bool, hidesBottomBar: Bool) { diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift index 0d89107c4..4a366bc94 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift @@ -64,7 +64,8 @@ class StackNavigatorTests: BitwardenTestCase { func test_pop() { subject.push(EmptyView(), animated: false) subject.push(EmptyView(), animated: false) - subject.pop(animated: false) + let viewController = subject.pop(animated: false) + XCTAssertTrue(viewController is UIHostingController) XCTAssertEqual(subject.viewControllers.count, 1) XCTAssertTrue(subject.topViewController is UIHostingController) } @@ -74,7 +75,8 @@ class StackNavigatorTests: BitwardenTestCase { subject.push(EmptyView(), animated: false) subject.push(EmptyView(), animated: false) subject.push(EmptyView(), animated: false) - subject.popToRoot(animated: false) + let viewControllers = subject.popToRoot(animated: false) + XCTAssertEqual(viewControllers.count, 2) XCTAssertEqual(subject.viewControllers.count, 1) XCTAssertTrue(subject.topViewController is UIHostingController) } diff --git a/GlobalTestHelpers/Extensions/InspectableView.swift b/GlobalTestHelpers/Extensions/InspectableView.swift new file mode 100644 index 000000000..65624c909 --- /dev/null +++ b/GlobalTestHelpers/Extensions/InspectableView.swift @@ -0,0 +1,35 @@ +import ViewInspector + +extension InspectableView { + /// Attempts to locate a button with the provided id. + /// + /// - Parameter id: The id to use while searching for a button. + /// - Returns: A button, if one can be located. + /// - Throws: Throws an error if a view was unable to be located. + /// + func find(buttonWithId id: AnyHashable) throws -> InspectableView { + try find(ViewType.Button.self) { view in + try view.id() == id + } + } + + /// Attempts to locate a text field with the provided label. + /// + /// - Parameter label: The label to use while searching for a text field. + /// - Returns: A text field, if one can be located. + /// - Throws: Throws an error if a view was unable to be located. + /// + func find(textField label: String) throws -> InspectableView { + try find(ViewType.TextField.self, containing: label) + } + + /// Attempts to locate a secure field with the provided label. + /// + /// - Parameter label: The label to use while searching for a secure field. + /// - Returns: A secure field, if one can be located. + /// - Throws: Throws an error if a view was unable to be located. + /// + func find(secureField label: String) throws -> InspectableView { + try find(ViewType.SecureField.self, containing: label) + } +} diff --git a/GlobalTestHelpers/MockStackNavigator.swift b/GlobalTestHelpers/MockStackNavigator.swift index 296cc56b0..e286ecd0d 100644 --- a/GlobalTestHelpers/MockStackNavigator.swift +++ b/GlobalTestHelpers/MockStackNavigator.swift @@ -22,6 +22,8 @@ final class MockStackNavigator: StackNavigator { var actions: [NavigationAction] = [] var rootViewController: UIViewController? + var viewControllersToPop: [UIViewController] = [] + func dismiss(animated: Bool) { actions.append(NavigationAction(type: .dismissed, animated: animated)) } @@ -30,12 +32,16 @@ final class MockStackNavigator: StackNavigator { actions.append(NavigationAction(type: .pushed, view: view, animated: animated)) } - func pop(animated: Bool) { + @discardableResult + func pop(animated: Bool) -> UIViewController? { actions.append(NavigationAction(type: .popped, animated: animated)) + return viewControllersToPop.last } - func popToRoot(animated: Bool) { + @discardableResult + func popToRoot(animated: Bool) -> [UIViewController] { actions.append(NavigationAction(type: .poppedToRoot, animated: animated)) + return viewControllersToPop } func present(_ view: Content, animated: Bool, overFullscreen: Bool) {