diff --git a/Bitwarden/Application/SceneDelegateTests.swift b/Bitwarden/Application/SceneDelegateTests.swift index 96016a557..632f5113f 100644 --- a/Bitwarden/Application/SceneDelegateTests.swift +++ b/Bitwarden/Application/SceneDelegateTests.swift @@ -8,7 +8,6 @@ import XCTest class SceneDelegateTests: BitwardenTestCase { // MARK: Properties - var appCoordinator: MockCoordinator! var appModule: MockAppModule! var subject: SceneDelegate! @@ -16,9 +15,7 @@ class SceneDelegateTests: BitwardenTestCase { override func setUp() { super.setUp() - appCoordinator = MockCoordinator() appModule = MockAppModule() - appModule.appCoordinator = appCoordinator.asAnyCoordinator() subject = SceneDelegate() subject.appModule = appModule } @@ -42,7 +39,7 @@ class SceneDelegateTests: BitwardenTestCase { XCTAssertNotNil(subject.appCoordinator) XCTAssertNotNil(subject.window) - XCTAssertTrue(appCoordinator.isStarted) + XCTAssertTrue(appModule.appCoordinator.isStarted) } /// `scene(_:willConnectTo:options:)` without a `UIWindowScene` fails to create the app's UI. @@ -56,6 +53,6 @@ class SceneDelegateTests: BitwardenTestCase { XCTAssertNil(subject.appCoordinator) XCTAssertNil(subject.window) - XCTAssertFalse(appCoordinator.isStarted) + XCTAssertFalse(appModule.appCoordinator.isStarted) } } diff --git a/BitwardenShared/UI/Auth/AuthCoordinator.swift b/BitwardenShared/UI/Auth/AuthCoordinator.swift new file mode 100644 index 000000000..407a9b818 --- /dev/null +++ b/BitwardenShared/UI/Auth/AuthCoordinator.swift @@ -0,0 +1,82 @@ +import SwiftUI +import UIKit + +// MARK: - AuthCoordinator + +/// A coordinator that manages navigation in the authentication flow. +/// +internal final class AuthCoordinator: Coordinator { + // MARK: Properties + + /// The root navigator used to display this coordinator's interface. + weak var rootNavigator: (any RootNavigator)? + + /// The stack navigator that is managed by this coordinator. + var stackNavigator: StackNavigator + + // MARK: Initialization + + /// Creates a new `AuthCoordinator`. + /// + /// - Parameters: + /// - rootNavigator: The root navigator used to display this coordinator's interface. + /// - stackNavigator: The stack navigator that is managed by this coordinator. + /// + init( + rootNavigator: RootNavigator, + stackNavigator: StackNavigator + ) { + self.rootNavigator = rootNavigator + self.stackNavigator = stackNavigator + } + + // MARK: Methods + + func navigate(to route: AuthRoute, context: AnyObject?) { + switch route { + case .createAccount: + showCreateAccount() + case .landing: + showLanding() + case .login: + showLogin() + case .regionSelection: + showRegionSelection() + } + } + + func start() { + rootNavigator?.show(child: stackNavigator) + } + + // MARK: Private Methods + + /// Shows the create account screen. + private func showCreateAccount() { + let view = Text("Create Account") + stackNavigator.push(view, animated: UI.animated) + } + + /// Shows the landing screen. + private func showLanding() { + let processor = LandingProcessor( + coordinator: asAnyCoordinator(), + state: LandingState() + ) + let store = Store(processor: processor) + let view = LandingView(store: store) + stackNavigator.push(view, animated: UI.animated) + } + + /// Shows the login screen. + private func showLogin() { + let view = Text("Login") + stackNavigator.push(view, animated: UI.animated) + } + + /// Shows the region selection screen. + private func showRegionSelection() { + let view = Text("Region") + stackNavigator.push(view, animated: UI.animated) + } +} diff --git a/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift new file mode 100644 index 000000000..3ebe984fb --- /dev/null +++ b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift @@ -0,0 +1,84 @@ +import SwiftUI +import XCTest + +@testable import BitwardenShared + +// MARK: - AuthCoordinatorTests + +class AuthCoordinatorTests: BitwardenTestCase { + // MARK: Properties + + var rootNavigator: MockRootNavigator! + var stackNavigator: MockStackNavigator! + var subject: AuthCoordinator! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + rootNavigator = MockRootNavigator() + stackNavigator = MockStackNavigator() + subject = AuthCoordinator( + rootNavigator: rootNavigator, + stackNavigator: stackNavigator + ) + } + + override func tearDown() { + super.tearDown() + rootNavigator = nil + stackNavigator = nil + subject = nil + } + + // MARK: Tests + + /// `navigate(to:)` with `.createAccount` pushes the create account view onto the stack navigator. + func test_navigate_createAccount() { + subject.navigate(to: .createAccount) + + // Placeholder assertion until the create account screen is added: BIT-157 + 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 `.login` pushes the login view onto the stack navigator. + func test_navigate_login() { + subject.navigate(to: .login) + + // Placeholder assertion until the login screen is added: BIT-83 + XCTAssertTrue(stackNavigator.actions.last?.view is Text) + } + + /// `navigate(to:)` with `.regionSelection` pushes the region selection view onto the stack navigator. + func test_navigate_regionSelection() { + subject.navigate(to: .regionSelection) + + // Placeholder assertion until the region selection screen is added: BIT-268 + XCTAssertTrue(stackNavigator.actions.last?.view is Text) + } + + /// `rootNavigator` uses a weak reference and does not retain a value once the root navigator has been erased. + func test_rootNavigator_resetWeakReference() { + var rootNavigator: MockRootNavigator? = MockRootNavigator() + subject = AuthCoordinator( + rootNavigator: rootNavigator!, + stackNavigator: stackNavigator + ) + XCTAssertNotNil(subject.rootNavigator) + + rootNavigator = nil + XCTAssertNil(subject.rootNavigator) + } + + /// `start()` presents the stack navigator within the root navigator. + func test_start() { + subject.start() + XCTAssertIdentical(rootNavigator.navigatorShown, stackNavigator) + } +} diff --git a/BitwardenShared/UI/Auth/AuthModule.swift b/BitwardenShared/UI/Auth/AuthModule.swift new file mode 100644 index 000000000..5eba8a981 --- /dev/null +++ b/BitwardenShared/UI/Auth/AuthModule.swift @@ -0,0 +1,32 @@ +import UIKit + +// MARK: - AuthModule + +/// An object that builds coordinators for the auth flow. +@MainActor +public protocol AuthModule { + /// Initializes a coordinator for navigating between `AuthRoute`s. + /// + /// - rootNavigator: The root navigator used to display this coordinator's interface. + /// - stackNavigator: The stack navigator that will be used to navigate between routes. + /// - Returns: A coordinator that can navigate to `AuthRoute`s. + /// + func makeAuthCoordinator( + rootNavigator: RootNavigator, + stackNavigator: StackNavigator + ) -> AnyCoordinator +} + +// MARK: - DefaultAppModule + +extension DefaultAppModule: AuthModule { + public func makeAuthCoordinator( + rootNavigator: RootNavigator, + stackNavigator: StackNavigator + ) -> AnyCoordinator { + AuthCoordinator( + rootNavigator: rootNavigator, + stackNavigator: stackNavigator + ).asAnyCoordinator() + } +} diff --git a/BitwardenShared/UI/Auth/AuthRoute.swift b/BitwardenShared/UI/Auth/AuthRoute.swift new file mode 100644 index 000000000..121442bd4 --- /dev/null +++ b/BitwardenShared/UI/Auth/AuthRoute.swift @@ -0,0 +1,16 @@ +// MARK: - AuthRoute + +/// A route to a specific screen in the authentication flow. +public enum AuthRoute: Equatable { + /// A route to the create account screen. + case createAccount + + /// A route to the landing screen. + case landing + + /// A route to the login screen. + case login + + /// A route to the region selection screen. + case regionSelection +} diff --git a/BitwardenShared/UI/Auth/Landing/LandingAction.swift b/BitwardenShared/UI/Auth/Landing/LandingAction.swift new file mode 100644 index 000000000..d83e5c665 --- /dev/null +++ b/BitwardenShared/UI/Auth/Landing/LandingAction.swift @@ -0,0 +1,19 @@ +// MARK: - LandingAction + +/// Actions that can be processed by a `LandingProcessor`. +enum LandingAction { + /// The continue button was pressed. + case continuePressed + + /// The create account button was pressed. + case createAccountPressed + + /// The value for the email was changed. + case emailChanged(String) + + /// The region button was pressed. + case regionPressed + + /// The value for the remember me toggle was changed. + case rememberMeChanged(Bool) +} diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift new file mode 100644 index 000000000..82c52344a --- /dev/null +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift @@ -0,0 +1,42 @@ +import Combine + +// MARK: - LandingProcessor + +/// The processor used to manage state and handle actions for the landing screen. +/// +class LandingProcessor: StateProcessor { + // MARK: Private Properties + + /// The coordinator that handles navigation. + private let coordinator: AnyCoordinator + + // MARK: Initialization + + /// Creates a new `LandingProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - state: The initial state of the processor. + /// + init(coordinator: AnyCoordinator, state: LandingState) { + self.coordinator = coordinator + super.init(state: state) + } + + // MARK: Methods + + override func receive(_ action: LandingAction) { + switch action { + case .continuePressed: + coordinator.navigate(to: .login) + case .createAccountPressed: + coordinator.navigate(to: .createAccount) + case let .emailChanged(newValue): + state.email = newValue + case .regionPressed: + coordinator.navigate(to: .regionSelection) + case let .rememberMeChanged(newValue): + state.isRememberMeOn = newValue + } + } +} diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift new file mode 100644 index 000000000..dc7c10257 --- /dev/null +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift @@ -0,0 +1,67 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - LandingProcessorTests + +class LandingProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var subject: LandingProcessor! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + coordinator = MockCoordinator() + + let state = LandingState() + subject = LandingProcessor( + coordinator: coordinator.asAnyCoordinator(), + state: state + ) + } + + override func tearDown() { + super.tearDown() + coordinator = nil + subject = nil + } + + // MARK: Tests + + /// `receive(_:)` with `.continuePressed` navigates to the login screen. + func test_receive_continuePressed() { + subject.receive(.continuePressed) + XCTAssertEqual(coordinator.routes.last, .login) + } + + /// `receive(_:)` with `.createAccountPressed` navigates to the create account screen. + func test_receive_createAccountPressed() { + subject.receive(.createAccountPressed) + XCTAssertEqual(coordinator.routes.last, .createAccount) + } + + /// `receive(_:)` with `.emailChanged` updates the state to reflect the changes. + func test_receive_emailChanged() { + XCTAssertEqual(subject.state.email, "") + + subject.receive(.emailChanged("email@example.com")) + XCTAssertEqual(subject.state.email, "email@example.com") + } + + /// `receive(_:)` with `.regionPressed` navigates to the region selection screen. + func test_receive_regionPressed() { + subject.receive(.regionPressed) + XCTAssertEqual(coordinator.routes.last, .regionSelection) + } + + /// `receive(_:)` with `.emailChanged` updates the state to reflect the changes. + func test_receive_rememberMeChanged() { + XCTAssertFalse(subject.state.isRememberMeOn) + + subject.receive(.rememberMeChanged(true)) + XCTAssertTrue(subject.state.isRememberMeOn) + } +} diff --git a/BitwardenShared/UI/Auth/Landing/LandingState.swift b/BitwardenShared/UI/Auth/Landing/LandingState.swift new file mode 100644 index 000000000..1053341bb --- /dev/null +++ b/BitwardenShared/UI/Auth/Landing/LandingState.swift @@ -0,0 +1,29 @@ +// MARK: - LandingState + +/// An object that defines the current state of a `LandingView`. +/// +struct LandingState: Equatable { + // MARK: Properties + + /// The email address provided by the user. + var email: String + + /// A flag indicating if the "Remember Me" toggle is on. + var isRememberMeOn: Bool + + // MARK: Initialization + + /// Creates a new `LandingState`. + /// + /// - Parameters: + /// - email: The email address provided by the user. + /// - isRememberMeOn: A flag indicating if the "Remember Me" toggle is on. + /// + init( + email: String = "", + isRememberMeOn: Bool = false + ) { + self.email = email + self.isRememberMeOn = isRememberMeOn + } +} diff --git a/BitwardenShared/UI/Auth/Landing/LandingView.swift b/BitwardenShared/UI/Auth/Landing/LandingView.swift new file mode 100644 index 000000000..30d73027e --- /dev/null +++ b/BitwardenShared/UI/Auth/Landing/LandingView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +// MARK: - LandingView + +/// A view that allows the user to input their email address to begin the login flow, +/// or allows the user to navigate to the account creation flow. +/// +struct LandingView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject public var store: Store + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Log in or create a new account to access your secure vault.") + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + TextField("Email", text: store.binding( + get: { $0.email }, + send: { .emailChanged($0) } + )) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + + Button { + store.send(.regionPressed) + } label: { + HStack(spacing: 4) { + Text("Region:") + .foregroundColor(.primary) + Text("US") + .foregroundColor(.blue) + Image(systemName: "chevron.down") + } + .font(.system(.footnote)) + } + + Toggle("Remember me", isOn: store.binding( + get: { $0.isRememberMeOn }, + send: { .rememberMeChanged($0) } + )) + + Button { + store.send(.continuePressed) + } label: { + Text("Continue") + .bold() + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + HStack(spacing: 4) { + Text("New around here?") + Button("Create account") { + store.send(.createAccountPressed) + } + } + .font(.system(.footnote)) + } + .padding(.horizontal) + } + .navigationBarTitle("Bitwarden", displayMode: .inline) + } +} + +// MARK: - Previews + +struct LandingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LandingView( + store: Store( + processor: StateProcessor( + state: LandingState( + email: "", + isRememberMeOn: false + ) + ) + ) + ) + } + .previewDisplayName("Empty Email") + + NavigationView { + LandingView( + store: Store( + processor: StateProcessor( + state: LandingState( + email: "email@example.com", + isRememberMeOn: true + ) + ) + ) + ) + } + .previewDisplayName("Example Email") + } +} diff --git a/BitwardenShared/UI/Platform/Application/AppCoordinator.swift b/BitwardenShared/UI/Platform/Application/AppCoordinator.swift index 8b1d55bd6..a92b224d1 100644 --- a/BitwardenShared/UI/Platform/Application/AppCoordinator.swift +++ b/BitwardenShared/UI/Platform/Application/AppCoordinator.swift @@ -5,8 +5,21 @@ import UIKit /// A coordinator that manages the app's top-level navigation. /// public class AppCoordinator: Coordinator { + // MARK: Types + + /// The types of modules used by this coordinator. + public typealias Module = AuthModule + + // MARK: Private Properties + + /// The coordinator currently being displayed. + private var childCoordinator: AnyObject? + // MARK: Properties + /// The module to use for creating child coordinators. + public let module: Module + /// The navigator to use for presenting screens. public let navigator: RootNavigator @@ -14,9 +27,12 @@ public class AppCoordinator: Coordinator { /// Creates a new `AppCoordinator`. /// - /// - Parameter navigator: The navigator to use for presenting screens. + /// - Parameters: + /// - module: The module to use for creating child coordinators. + /// - navigator: The navigator to use for presenting screens. /// - public init(navigator: RootNavigator) { + public init(module: Module, navigator: RootNavigator) { + self.module = module self.navigator = navigator } @@ -24,23 +40,33 @@ public class AppCoordinator: Coordinator { public func navigate(to route: AppRoute, context: AnyObject?) { switch route { - case .onboarding: - showOnboarding() + case let .auth(authRoute): + showAuth(route: authRoute) } } public func start() { - showOnboarding() + showAuth(route: .landing) } // MARK: Private Methods - /// Shows the onboarding navigator. - private func showOnboarding() { - // Temporary view controller for testing purposes. Will be replaced with real functionality in BIT-155 - let viewController = UIViewController() - viewController.view.backgroundColor = .systemBlue - let navController = UINavigationController(rootViewController: viewController) - navigator.show(child: navController) + /// Shows the auth route. + /// + /// - Parameter route: The auth route to show. + /// + private func showAuth(route: AuthRoute) { + if let coordinator = childCoordinator as? AnyCoordinator { + coordinator.navigate(to: route) + } else { + let navigationController = UINavigationController() + let coordinator = module.makeAuthCoordinator( + rootNavigator: navigator, + stackNavigator: navigationController + ) + coordinator.start() + coordinator.navigate(to: route) + childCoordinator = coordinator + } } } diff --git a/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift b/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift index 58312ae87..8d8a94090 100644 --- a/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift @@ -8,6 +8,7 @@ import XCTest class AppCoordinatorTests: BitwardenTestCase { // MARK: Properties + var module: MockAppModule! var navigator: MockRootNavigator! var subject: AppCoordinator! @@ -15,31 +16,49 @@ class AppCoordinatorTests: BitwardenTestCase { override func setUp() { super.setUp() + module = MockAppModule() navigator = MockRootNavigator() - subject = AppCoordinator(navigator: navigator) + subject = AppCoordinator( + module: module, + navigator: navigator + ) } override func tearDown() { super.tearDown() + module = nil navigator = nil subject = nil } // MARK: Tests - /// `start()` initializes the UI correctly. + /// `navigate(to:)` with `.onboarding` starts the auth coordinator and navigates to the proper auth route. + func test_navigateTo_auth() throws { + subject.navigate(to: .auth(.landing)) + + XCTAssertTrue(module.authCoordinator.isStarted) + XCTAssertEqual(module.authCoordinator.routes, [.landing]) + } + + /// `navigate(to:)` with `.auth(.landing)` twice uses the existing coordinator, rather than creating a new one. + func test_navigateTo_authTwice() { + subject.navigate(to: .auth(.landing)) + subject.navigate(to: .auth(.landing)) + + XCTAssertEqual(module.authCoordinator.routes, [.landing, .landing]) + } + + /// `start()` initializes the interface correctly. func test_start() { subject.start() - // Placeholder assertion until functionality is implemented in BIT-155 - XCTAssertTrue(navigator.navigatorShown is StackNavigator) + XCTAssertTrue(module.authCoordinator.isStarted) } - /// `navigate(to:)` with `.onboarding` presents the correct navigator. + /// `navigate(to:)` with `.auth(.landing)` presents the correct navigator. func test_navigateTo_onboarding() throws { - subject.navigate(to: .onboarding) - - // Placeholder assertion until functionality is implemented in BIT-155 - XCTAssertTrue(navigator.navigatorShown is StackNavigator) + subject.navigate(to: .auth(.landing)) + XCTAssertEqual(module.authCoordinator.routes, [.landing]) } } diff --git a/BitwardenShared/UI/Platform/Application/AppModule.swift b/BitwardenShared/UI/Platform/Application/AppModule.swift index 561e198ca..95a2ff3bf 100644 --- a/BitwardenShared/UI/Platform/Application/AppModule.swift +++ b/BitwardenShared/UI/Platform/Application/AppModule.swift @@ -22,7 +22,9 @@ public class DefaultAppModule { extension DefaultAppModule: AppModule { public func makeAppCoordinator(navigator: RootNavigator) -> AnyCoordinator { - AppCoordinator(navigator: navigator) - .asAnyCoordinator() + AppCoordinator( + module: self, + navigator: navigator + ).asAnyCoordinator() } } diff --git a/BitwardenShared/UI/Platform/Application/AppRoute.swift b/BitwardenShared/UI/Platform/Application/AppRoute.swift index 63a9de937..22e23fd46 100644 --- a/BitwardenShared/UI/Platform/Application/AppRoute.swift +++ b/BitwardenShared/UI/Platform/Application/AppRoute.swift @@ -1,5 +1,8 @@ +// MARK: - AppRoute + /// A top level route from the initial screen of the app to anywhere in the app. /// public enum AppRoute: Equatable { - case onboarding + /// A route to the authentication flow. + case auth(AuthRoute) } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift index 61f96bfd7..178bd730e 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift @@ -35,8 +35,8 @@ class AnyCoordinatorTests: BitwardenTestCase { /// `navigate(to:context:)` calls the `navigate(to:context:)` method on the wrapped coordinator. func test_navigate_onboarding() { - subject.navigate(to: .onboarding, context: "🤖" as NSString) + subject.navigate(to: .auth(.landing), context: "🤖" as NSString) XCTAssertEqual(coordinator.contexts as? [NSString], ["🤖" as NSString]) - XCTAssertEqual(coordinator.routes, [.onboarding]) + XCTAssertEqual(coordinator.routes, [.auth(.landing)]) } } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/Coordinator.swift b/BitwardenShared/UI/Platform/Application/Utilities/Coordinator.swift index 69e0451a9..ac4f60b32 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/Coordinator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/Coordinator.swift @@ -1,6 +1,6 @@ /// A protocol for an object that performs navigation via routes. @MainActor -public protocol Coordinator: AnyObject { +public protocol Coordinator: AnyObject { associatedtype Route /// Navigate to the screen associated with the given `Route` with the given context. diff --git a/GlobalTestHelpers/MockAppModule.swift b/GlobalTestHelpers/MockAppModule.swift index 16c1546b0..d8f9edd38 100644 --- a/GlobalTestHelpers/MockAppModule.swift +++ b/GlobalTestHelpers/MockAppModule.swift @@ -2,12 +2,20 @@ import BitwardenShared // MARK: - MockAppModule -class MockAppModule: AppModule { - var appCoordinator: AnyCoordinator? +class MockAppModule: AppModule, AuthModule { + var appCoordinator = MockCoordinator() + var authCoordinator = MockCoordinator() func makeAppCoordinator( navigator: RootNavigator ) -> AnyCoordinator { - appCoordinator ?? MockCoordinator().asAnyCoordinator() + appCoordinator.asAnyCoordinator() + } + + func makeAuthCoordinator( + rootNavigator: RootNavigator, + stackNavigator: StackNavigator + ) -> AnyCoordinator { + authCoordinator.asAnyCoordinator() } }