SwiftUI NavigationController and UserNotificationControllers (#309)

* Fixes #286 - Adopted the new SwiftUI NavigationStack based NavigationController throughout the application
* Fixes #315 - Implemented new user notification components on top of SwiftUI and the new navigation flows
* Add home screen fade animation between skeletons and real rooms
* Bump the danger-swift version used on the CI and swiftlint with it
* Renamed Splash to Onboarding, Empty to Splash
This commit is contained in:
Stefan Ceriu
2022-11-16 15:37:34 +02:00
committed by GitHub
parent af85c770da
commit 2fd0491a18
108 changed files with 1627 additions and 3720 deletions
@@ -14,7 +14,7 @@
// limitations under the License.
//
import UIKit
import SwiftUI
@MainActor
protocol AuthenticationCoordinatorDelegate: AnyObject {
@@ -22,42 +22,31 @@ protocol AuthenticationCoordinatorDelegate: AnyObject {
didLoginWithSession userSession: UserSessionProtocol)
}
class AuthenticationCoordinator: Coordinator, Presentable {
class AuthenticationCoordinator: CoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol
private let navigationRouter: NavigationRouter
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
var childCoordinators: [Coordinator] = []
private let navigationController: NavigationController
weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
navigationRouter: NavigationRouter) {
navigationController: NavigationController) {
self.authenticationService = authenticationService
self.navigationRouter = navigationRouter
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: navigationRouter.toPresentable())
self.navigationController = navigationController
}
func start() {
showSplashScreen()
showOnboarding()
}
func toPresentable() -> UIViewController {
navigationRouter.toPresentable()
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Shows the splash screen as the root view in the navigation stack.
private func showSplashScreen() {
let coordinator = SplashScreenCoordinator()
private func showOnboarding() {
let coordinator = OnboardingCoordinator()
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
@@ -66,12 +55,7 @@ class AuthenticationCoordinator: Coordinator, Presentable {
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.setRootCoordinator(coordinator)
}
private func startAuthentication() async {
@@ -89,7 +73,8 @@ class AuthenticationCoordinator: Coordinator, Presentable {
private func showServerSelectionScreen() {
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: false)
userNotificationController: ServiceLocator.shared.userNotificationController,
isModallyPresented: false)
let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -103,34 +88,24 @@ class AuthenticationCoordinator: Coordinator, Presentable {
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.push(coordinator)
}
private func showLoginScreen() {
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
navigationRouter: navigationRouter)
navigationController: navigationController)
let coordinator = LoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .signedIn(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.push(coordinator)
}
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
@@ -141,22 +116,20 @@ class AuthenticationCoordinator: Coordinator, Presentable {
guard let self else { return }
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.setRootCoordinator(coordinator)
}
/// Show a blocking activity indicator.
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
private func startLoading() {
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.loading,
persistent: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -21,7 +21,7 @@ struct LoginCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol
/// The navigation router used to present the server selection screen.
let navigationRouter: NavigationRouterType
let navigationController: NavigationController
}
enum LoginCoordinatorAction {
@@ -29,14 +29,10 @@ enum LoginCoordinatorAction {
case signedIn(UserSessionProtocol)
}
final class LoginCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class LoginCoordinator: CoordinatorProtocol {
private let parameters: LoginCoordinatorParameters
private let loginHostingController: UIViewController
private var loginViewModel: LoginViewModelProtocol
private var viewModel: LoginViewModelProtocol
private let hostingController: UIViewController
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS?
@@ -47,14 +43,8 @@ final class LoginCoordinator: Coordinator, Presentable {
}
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
private var navigationController: NavigationController { parameters.navigationController }
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
// MARK: - Setup
@@ -62,22 +52,16 @@ final class LoginCoordinator: Coordinator, Presentable {
init(parameters: LoginCoordinatorParameters) {
self.parameters = parameters
let viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
loginViewModel = viewModel
viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
let view = LoginScreen(context: viewModel.context)
loginHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: loginHostingController)
hostingController = UIHostingController(rootView: LoginScreen(context: viewModel.context))
oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
loginViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("LoginViewModel did callback with result: \(action).")
@@ -95,51 +79,52 @@ final class LoginCoordinator: Coordinator, Presentable {
}
}
}
func toPresentable() -> UIViewController {
loginHostingController
}
func stop() {
stopLoading()
}
func toPresentable() -> AnyView {
AnyView(LoginScreen(context: viewModel.context))
}
// MARK: - Private
/// Show a blocking activity indicator whilst saving.
static let loadingIndicatorIdentifier = "LoginCoordinatorLoading"
private func startLoading(isInteractionBlocking: Bool) {
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: isInteractionBlocking))
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.loading,
persistent: true))
if !isInteractionBlocking {
loginViewModel.update(isLoading: true)
viewModel.update(isLoading: true)
}
}
/// Show a non-blocking indicator that an operation was successful.
private func indicateSuccess() {
activityIndicator = indicatorPresenter.present(.success(label: ElementL10n.dialogTitleSuccess))
}
/// Show a non-blocking indicator that an operation failed.
private func indicateFailure() {
activityIndicator = indicatorPresenter.present(.error(label: ElementL10n.dialogTitleError))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loginViewModel.update(isLoading: false)
activityIndicator = nil
viewModel.update(isLoading: false)
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
private func indicateSuccess() {
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.dialogTitleSuccess))
}
private func indicateFailure() {
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.dialogTitleError))
}
/// Processes an error to either update the flow or display it to the user.
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidCredentials:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default:
loginViewModel.displayError(.alert(ElementL10n.unknownError))
viewModel.displayError(.alert(ElementL10n.unknownError))
}
}
@@ -204,44 +189,43 @@ final class LoginCoordinator: Coordinator, Presentable {
/// Updates the view model with a different homeserver.
private func updateViewModel() {
loginViewModel.update(homeserver: authenticationService.homeserver)
viewModel.update(homeserver: authenticationService.homeserver)
indicateSuccess()
}
/// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() {
MXLog.debug("PresentServerSelectionScreen")
let serverSelectionNavigationController = NavigationController()
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationController)
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: true)
userNotificationController: userNotificationController,
isModallyPresented: true)
let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] action in
guard let self, let coordinator = coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
}
coordinator.start()
add(childCoordinator: coordinator)
serverSelectionNavigationController.setRootCoordinator(coordinator)
let modalRouter = NavigationRouter(navigationController: ElementNavigationController())
modalRouter.setRootModule(coordinator)
navigationRouter.present(modalRouter, animated: true)
navigationController.presentSheet(userNotificationController)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator,
didCompleteWith action: ServerSelectionCoordinatorAction) {
navigationRouter.dismissModule(animated: true) { [weak self] in
if action == .updated {
self?.updateViewModel()
}
self?.remove(childCoordinator: coordinator)
if action == .updated {
updateViewModel()
}
navigationController.dismissSheet()
}
/// Shows the forgot password screen.
private func showForgotPasswordScreen() {
loginViewModel.displayError(.alert("Not implemented."))
viewModel.displayError(.alert("Not implemented."))
}
}
@@ -27,18 +27,18 @@ enum MockServerSelectionScreenState: CaseIterable {
switch self {
case .matrix:
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
hasModalPresentation: true)
isModallyPresented: true)
case .emptyAddress:
return ServerSelectionViewModel(homeserverAddress: "",
hasModalPresentation: true)
isModallyPresented: true)
case .invalidAddress:
let viewModel = ServerSelectionViewModel(homeserverAddress: "thisisbad",
hasModalPresentation: true)
isModallyPresented: true)
viewModel.displayError(.footerMessage(ElementL10n.unknownError))
return viewModel
case .nonModal:
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
hasModalPresentation: false)
isModallyPresented: false)
}
}
}
@@ -19,8 +19,9 @@ import SwiftUI
struct ServerSelectionCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol
let userNotificationController: UserNotificationControllerProtocol
/// Whether the screen is presented modally or within a navigation stack.
let hasModalPresentation: Bool
let isModallyPresented: Bool
}
enum ServerSelectionCoordinatorAction {
@@ -28,45 +29,25 @@ enum ServerSelectionCoordinatorAction {
case dismiss
}
final class ServerSelectionCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class ServerSelectionCoordinator: CoordinatorProtocol {
private let parameters: ServerSelectionCoordinatorParameters
private let serverSelectionHostingController: UIViewController
private var serverSelectionViewModel: ServerSelectionViewModelProtocol
private let userNotificationController: UserNotificationControllerProtocol
private var viewModel: ServerSelectionViewModelProtocol
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (ServerSelectionCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: ServerSelectionCoordinatorParameters) {
self.parameters = parameters
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
hasModalPresentation: parameters.hasModalPresentation)
let view = ServerSelectionScreen(context: viewModel.context)
serverSelectionViewModel = viewModel
serverSelectionHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: serverSelectionHostingController)
viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
isModallyPresented: parameters.isModallyPresented)
userNotificationController = parameters.userNotificationController
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
serverSelectionViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("ServerSelectionViewModel did callback with action: \(action).")
@@ -79,27 +60,24 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
serverSelectionHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
func toPresentable() -> AnyView {
AnyView(ServerSelectionScreen(context: viewModel.context))
}
// MARK: - Private
private func startLoading(label: String = ElementL10n.loading) {
userNotificationController.submitNotification(UserNotification(type: .modal,
title: label,
persistent: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
userNotificationController.retractAllNotifications()
}
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
@@ -122,9 +100,9 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidServer, .invalidHomeserverAddress:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
viewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
default:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.unknownError))
viewModel.displayError(.footerMessage(ElementL10n.unknownError))
}
}
}
@@ -33,7 +33,7 @@ struct ServerSelectionViewState: BindableState {
/// An error message to be shown in the text field footer.
var footerErrorMessage: String?
/// Whether the screen is presented modally or within a navigation stack.
var hasModalPresentation: Bool
var isModallyPresented: Bool
/// The message to show in the text field footer.
var footerMessage: String {
@@ -42,7 +42,7 @@ struct ServerSelectionViewState: BindableState {
/// The title shown on the confirm button.
var buttonTitle: String {
hasModalPresentation ? ElementL10n.actionConfirm : ElementL10n.actionNext
isModallyPresented ? ElementL10n.actionConfirm : ElementL10n.actionNext
}
/// The text field is showing an error.
@@ -28,13 +28,13 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)?
// MARK: - Setup
init(homeserverAddress: String, hasModalPresentation: Bool) {
init(homeserverAddress: String, isModallyPresented: Bool) {
let bindings = ServerSelectionBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: ServerSelectionViewState(bindings: bindings,
hasModalPresentation: hasModalPresentation))
isModallyPresented: isModallyPresented))
}
// MARK: - Public
override func process(viewAction: ServerSelectionViewAction) async {
@@ -44,6 +44,7 @@ struct ServerSelectionScreen: View {
.background(Color.element.background, ignoresSafeAreaEdges: .all)
.toolbar { toolbar }
.alert(item: $context.alertInfo) { $0.alert }
.interactiveDismissDisabled()
}
/// The title, message and icon at the top of the screen.
@@ -91,7 +92,7 @@ struct ServerSelectionScreen: View {
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
if context.viewState.hasModalPresentation {
if context.viewState.isModallyPresented {
Button { context.send(viewAction: .dismiss) } label: {
Text(ElementL10n.actionCancel)
}
@@ -40,55 +40,35 @@ enum SoftLogoutCoordinatorResult: CustomStringConvertible {
}
}
final class SoftLogoutCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class SoftLogoutCoordinator: CoordinatorProtocol {
private let parameters: SoftLogoutCoordinatorParameters
private let softLogoutHostingController: UIViewController
private var softLogoutViewModel: SoftLogoutViewModelProtocol
private var viewModel: SoftLogoutViewModelProtocol
private let hostingController: UIViewController
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS?
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var successIndicator: UserIndicator?
/// The wizard used to handle the registration flow.
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: SoftLogoutCoordinatorParameters) {
self.parameters = parameters
let homeserver = parameters.authenticationService.homeserver
let viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
homeserver: homeserver,
keyBackupNeeded: parameters.keyBackupNeeded)
softLogoutViewModel = viewModel
let view = SoftLogoutScreen(context: viewModel.context)
softLogoutHostingController = UIHostingController(rootView: view)
viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
homeserver: homeserver,
keyBackupNeeded: parameters.keyBackupNeeded)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: softLogoutHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: softLogoutHostingController)
hostingController = UIHostingController(rootView: SoftLogoutScreen(context: viewModel.context))
oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[SoftLogoutCoordinator] did start.")
softLogoutViewModel.callback = { [weak self] result in
viewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).")
@@ -105,31 +85,36 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
softLogoutHostingController
}
func stop() {
stopLoading()
}
func toPresentable() -> AnyView {
AnyView(SoftLogoutScreen(context: viewModel.context))
}
// MARK: - Private
static let loadingIndicatorIdentifier = "SoftLogoutLoading"
/// Show an activity indicator whilst loading.
@MainActor private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.loading,
persistent: true))
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
loadingIndicator = nil
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
/// Shows the forgot password screen.
@MainActor private func showForgotPasswordScreen() {
MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen")
softLogoutViewModel.displayError(.alert("Not implemented."))
viewModel.displayError(.alert("Not implemented."))
}
/// Login with the supplied username and password.
@@ -177,11 +162,11 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidCredentials:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default:
softLogoutViewModel.displayError(.alert(ElementL10n.unknownError))
viewModel.displayError(.alert(ElementL10n.unknownError))
}
}
}