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
@@ -21,39 +21,22 @@ struct AnalyticsPromptCoordinatorParameters {
let userSession: UserSessionProtocol
}
final class AnalyticsPromptCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class AnalyticsPromptCoordinator: CoordinatorProtocol {
private let parameters: AnalyticsPromptCoordinatorParameters
private let analyticsPromptHostingController: UIViewController
private var analyticsPromptViewModel: AnalyticsPromptViewModel
// MARK: Public
private var viewModel: AnalyticsPromptViewModel
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor () -> Void)?
// MARK: - Setup
init(parameters: AnalyticsPromptCoordinatorParameters) {
self.parameters = parameters
let viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
let view = AnalyticsPrompt(context: viewModel.context)
analyticsPromptViewModel = viewModel
analyticsPromptHostingController = UIHostingController(rootView: view)
viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
analyticsPromptViewModel.callback = { [weak self] result in
viewModel.callback = { [weak self] result in
MXLog.debug("AnalyticsPromptViewModel did complete with result: \(result).")
guard let self else { return }
@@ -69,7 +52,7 @@ final class AnalyticsPromptCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController { analyticsPromptHostingController }
func stop() { }
func toPresentable() -> AnyView {
AnyView(AnalyticsPrompt(context: viewModel.context))
}
}
@@ -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))
}
}
}
@@ -16,90 +16,77 @@
import SwiftUI
struct BugReportCoordinatorParameters {
let bugReportService: BugReportServiceProtocol
let screenshot: UIImage?
enum BugReportCoordinatorResult {
case cancel
case finish
}
final class BugReportCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: BugReportCoordinatorParameters
private let bugReportHostingController: UIViewController
private var bugReportViewModel: BugReportViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
// MARK: Public
struct BugReportCoordinatorParameters {
let bugReportService: BugReportServiceProtocol
let userNotificationController: UserNotificationControllerProtocol
let screenshot: UIImage?
let isModallyPresented: Bool
}
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
final class BugReportCoordinator: CoordinatorProtocol {
private let parameters: BugReportCoordinatorParameters
private var viewModel: BugReportViewModelProtocol
var completion: ((BugReportCoordinatorResult) -> Void)?
init(parameters: BugReportCoordinatorParameters) {
self.parameters = parameters
let viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
screenshot: parameters.screenshot)
let view = BugReportScreen(context: viewModel.context)
bugReportViewModel = viewModel
bugReportHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: bugReportHostingController)
viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
screenshot: parameters.screenshot,
isModallyPresented: parameters.isModallyPresented)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
bugReportViewModel.callback = { [weak self] result in
viewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.debug("BugReportViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.completion?(.cancel)
case .submitStarted:
self.startLoading()
case .submitFinished:
self.stopLoading()
self.completion?()
self.completion?(.finish)
case .submitFailed(let error):
self.stopLoading()
self.showError(label: error.localizedDescription)
}
}
}
func toPresentable() -> UIViewController {
bugReportHostingController
}
func stop() {
stopLoading()
}
func toPresentable() -> AnyView {
AnyView(BugReportScreen(context: viewModel.context))
}
// 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))
static let loadingIndicatorIdentifier = "BugReportLoading"
private func startLoading(label: String = ElementL10n.loading) {
parameters.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: label,
persistent: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
parameters.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
/// Show error indicator
private func showError(label: String) {
statusIndicator = indicatorPresenter.present(.error(label: label))
parameters.userNotificationController.submitNotification(UserNotification(title: label))
}
}
@@ -22,6 +22,7 @@ import UIKit
// MARK: View model
enum BugReportViewModelAction {
case cancel
case submitStarted
case submitFinished
case submitFailed(error: Error)
@@ -32,6 +33,7 @@ enum BugReportViewModelAction {
struct BugReportViewState: BindableState {
var screenshot: UIImage?
var bindings: BugReportViewStateBindings
let isModallyPresented: Bool
}
struct BugReportViewStateBindings {
@@ -40,6 +42,7 @@ struct BugReportViewStateBindings {
}
enum BugReportViewAction {
case cancel
case submit
case toggleSendLogs
case removeScreenshot
@@ -16,18 +16,41 @@
import SwiftUI
@available(iOS 14, *)
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState,
BugReportViewAction>
@available(iOS 14, *)
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
// MARK: - Properties
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState, BugReportViewAction>
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
let bugReportService: BugReportServiceProtocol
var callback: ((BugReportViewModelAction) -> Void)?
init(bugReportService: BugReportServiceProtocol,
screenshot: UIImage?,
isModallyPresented: Bool) {
self.bugReportService = bugReportService
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
bindings: bindings,
isModallyPresented: isModallyPresented))
}
// MARK: - Public
override func process(viewAction: BugReportViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
case .submit:
await submitBugReport()
case .toggleSendLogs:
context.sendingLogsEnabled.toggle()
case .removeScreenshot:
state.screenshot = nil
}
}
// MARK: Private
func submitBugReport() async {
private func submitBugReport() async {
callback?(.submitStarted)
do {
var files: [URL] = []
@@ -54,31 +77,4 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
callback?(.submitFailed(error: error))
}
}
// MARK: Public
var callback: ((BugReportViewModelAction) -> Void)?
// MARK: - Setup
init(bugReportService: BugReportServiceProtocol,
screenshot: UIImage?) {
self.bugReportService = bugReportService
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
bindings: bindings))
}
// MARK: - Public
override func process(viewAction: BugReportViewAction) async {
switch viewAction {
case .submit:
await submitBugReport()
case .toggleSendLogs:
context.sendingLogsEnabled.toggle()
case .removeScreenshot:
state.screenshot = nil
}
}
}
@@ -19,6 +19,5 @@ import Foundation
@MainActor
protocol BugReportViewModelProtocol {
var callback: ((BugReportViewModelAction) -> Void)? { get set }
@available(iOS 14, *)
var context: BugReportViewModelType.Context { get }
}
@@ -48,6 +48,16 @@ struct BugReportScreen: View {
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
}
.navigationTitle(ElementL10n.titleActivityBugReport)
.toolbar {
if context.viewState.isModallyPresented {
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
.interactiveDismissDisabled()
}
}
@@ -136,7 +146,7 @@ struct BugReport_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image, isModallyPresented: false)
BugReportScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
@@ -25,42 +25,22 @@ enum FilePreviewCoordinatorAction {
case cancel
}
final class FilePreviewCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class FilePreviewCoordinator: CoordinatorProtocol {
private let parameters: FilePreviewCoordinatorParameters
private let filePreviewHostingController: UIViewController
private var filePreviewViewModel: FilePreviewViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
private var viewModel: FilePreviewViewModelProtocol
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((FilePreviewCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: FilePreviewCoordinatorParameters) {
self.parameters = parameters
let viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
let view = FilePreviewScreen(context: viewModel.context)
filePreviewViewModel = viewModel
filePreviewHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: filePreviewHostingController)
viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
filePreviewViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("FilePreviewViewModel did complete with result: \(action).")
switch action {
@@ -70,26 +50,7 @@ final class FilePreviewCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
filePreviewHostingController
}
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) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
func toPresentable() -> AnyView {
AnyView(FilePreviewScreen(context: viewModel.context))
}
}
@@ -20,6 +20,8 @@ import SwiftUI
struct HomeScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let attributedStringBuilder: AttributedStringBuilderProtocol
let bugReportService: BugReportServiceProtocol
let navigationController: NavigationController
}
enum HomeScreenCoordinatorAction {
@@ -30,34 +32,20 @@ enum HomeScreenCoordinatorAction {
case signOut
}
final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class HomeScreenCoordinator: CoordinatorProtocol {
private let parameters: HomeScreenCoordinatorParameters
private let hostingController: UIViewController
private var viewModel: HomeScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((HomeScreenCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
attributedStringBuilder: parameters.attributedStringBuilder)
let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
viewModel.callback = { [weak self] action in
guard let self else { return }
@@ -74,13 +62,21 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Public
func start() { }
func toPresentable() -> UIViewController {
hostingController
func start() {
if parameters.bugReportService.crashedLastRun {
viewModel.presentAlert(
AlertInfo(id: UUID(),
title: ElementL10n.sendBugReportAppCrashed,
primaryButton: .init(title: ElementL10n.no, action: nil),
secondaryButton: .init(title: ElementL10n.yes) { [weak self] in
self?.callback?(.presentFeedbackScreen)
}))
}
}
func toPresentable() -> AnyView {
AnyView(HomeScreen(context: viewModel.context))
}
func stop() { }
// MARK: - Private
@@ -98,11 +94,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
}
private func presentInviteFriends() {
guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: parameters.userSession.userID).absoluteString else {
return
}
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
let vc = UIActivityViewController(activityItems: [shareText], applicationActivities: nil)
hostingController.present(vc, animated: true)
parameters.navigationController.presentSheet(InviteFriendsCoordinator(userId: parameters.userSession.userID))
}
}
@@ -52,7 +52,7 @@ struct HomeScreenViewState: BindableState {
var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .rooms
var roomListMode: HomeScreenRoomListMode = .skeletons
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
@@ -77,6 +77,8 @@ struct HomeScreenViewState: BindableState {
struct HomeScreenViewStateBindings {
var searchQuery = ""
var alertInfo: AlertInfo<UUID>?
}
struct HomeScreenRoom: Identifiable, Equatable {
@@ -92,15 +94,12 @@ struct HomeScreenRoom: Identifiable, Equatable {
var avatar: UIImage?
var isPlaceholder = false
static func placeholder(id: String) -> HomeScreenRoom {
HomeScreenRoom(id: id,
name: "Placeholder room name",
hasUnreads: false,
timestamp: "Now",
lastMessage: AttributedString("Last message"),
avatar: UIImage(systemName: "photo"),
isPlaceholder: true)
avatar: UIImage(systemName: "photo"))
}
}
@@ -111,6 +111,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
}
func presentAlert(_ alertInfo: AlertInfo<UUID>) {
state.bindings.alertInfo = alertInfo
}
// MARK: - Private
private func loadDataForRoomIdentifier(_ identifier: String) {
@@ -22,4 +22,6 @@ protocol HomeScreenViewModelProtocol {
var callback: ((HomeScreenViewModelAction) -> Void)? { get set }
var context: HomeScreenViewModelType.Context { get }
func presentAlert(_ alert: AlertInfo<UUID>)
}
@@ -28,23 +28,30 @@ struct HomeScreen: View {
sessionVerificationBanner
}
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
if room.isPlaceholder {
if context.viewState.roomListMode == .skeletons {
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context)
.redacted(reason: .placeholder)
.disabled(true)
} else {
}
}
.padding(.horizontal)
} else {
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context)
}
}
.padding(.horizontal)
.searchable(text: $context.searchQuery)
}
.padding(.horizontal)
.searchable(text: $context.searchQuery)
}
.disabled(context.viewState.roomListMode == .skeletons)
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
.animation(.elementDefault, value: context.viewState.roomListMode)
.ignoresSafeArea(.all, edges: .bottom)
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.allChats)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -24,42 +24,23 @@ enum MediaPlayerCoordinatorAction {
case cancel
}
final class MediaPlayerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class MediaPlayerCoordinator: CoordinatorProtocol {
private let parameters: MediaPlayerCoordinatorParameters
private let mediaPlayerHostingController: UIViewController
private var mediaPlayerViewModel: MediaPlayerViewModelProtocol
private var viewModel: MediaPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((MediaPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: MediaPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
let view = MediaPlayerScreen(context: viewModel.context)
mediaPlayerViewModel = viewModel
mediaPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mediaPlayerHostingController)
viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
mediaPlayerViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("MediaPlayerViewModel did complete with result: \(action).")
switch action {
@@ -69,26 +50,7 @@ final class MediaPlayerCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
mediaPlayerHostingController
}
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) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
func toPresentable() -> AnyView {
AnyView(MediaPlayerScreen(context: viewModel.context))
}
}
@@ -0,0 +1,44 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
final class OnboardingCoordinator: CoordinatorProtocol {
private var viewModel: OnboardingViewModelProtocol
var callback: ((OnboardingCoordinatorAction) -> Void)?
init() {
viewModel = OnboardingViewModel()
}
// MARK: - Public
func start() {
viewModel.callback = { [weak self] action in
MXLog.debug("OnboardingViewModel did complete with result: \(action).")
guard let self else { return }
switch action {
case .login:
self.callback?(.login)
}
}
}
func toPresentable() -> AnyView {
AnyView(OnboardingScreen(context: viewModel.context))
}
}
@@ -18,12 +18,12 @@ import SwiftUI
// MARK: - Coordinator
enum SplashScreenCoordinatorAction {
enum OnboardingCoordinatorAction {
case login
}
/// The content displayed in a single splash screen page.
struct SplashScreenPageContent {
/// The content displayed in a single screen page.
struct OnboardingPageContent {
let title: AttributedString
let message: String
let image: ImageAsset
@@ -31,13 +31,13 @@ struct SplashScreenPageContent {
// MARK: View model
enum SplashScreenViewModelAction {
enum OnboardingViewModelAction {
case login
}
// MARK: View
struct SplashScreenViewState: BindableState {
struct OnboardingViewState: BindableState {
/// The colours of the background gradient shown behind the 4 pages.
private let gradientColors = [
Color(red: 0.95, green: 0.98, blue: 0.96),
@@ -48,8 +48,8 @@ struct SplashScreenViewState: BindableState {
]
/// An array containing all content of the carousel pages
let content: [SplashScreenPageContent]
var bindings: SplashScreenBindings
let content: [OnboardingPageContent]
var bindings: OnboardingBindings
/// The background gradient for all 4 pages and the hidden page at the start of the carousel.
var backgroundGradient: Gradient {
@@ -69,27 +69,27 @@ struct SplashScreenViewState: BindableState {
let page4Title = locale.identifier.hasPrefix("en") ? "Cut the slack from teams." : ElementL10n.ftueAuthCarouselWorkplaceTitle
content = [
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselSecureBody,
image: Asset.Images.splashScreenPage1),
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselControlBody,
image: Asset.Images.splashScreenPage2),
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselEncryptedBody,
image: Asset.Images.splashScreenPage3),
SplashScreenPageContent(title: page4Title.tinting("."),
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
image: Asset.Images.splashScreenPage4)
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselSecureBody,
image: Asset.Images.onboardingScreenPage1),
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselControlBody,
image: Asset.Images.onboardingScreenPage2),
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselEncryptedBody,
image: Asset.Images.onboardingScreenPage3),
OnboardingPageContent(title: page4Title.tinting("."),
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
image: Asset.Images.onboardingScreenPage4)
]
bindings = SplashScreenBindings()
bindings = OnboardingBindings()
}
}
struct SplashScreenBindings {
struct OnboardingBindings {
var pageIndex = 0
}
enum SplashScreenViewAction {
enum OnboardingViewAction {
case login
}
@@ -17,26 +17,26 @@
import Combine
import SwiftUI
typealias SplashScreenViewModelType = StateStoreViewModel<SplashScreenViewState, SplashScreenViewAction>
typealias OnboardingViewModelType = StateStoreViewModel<OnboardingViewState, OnboardingViewAction>
class SplashScreenViewModel: SplashScreenViewModelType, SplashScreenViewModelProtocol {
class OnboardingViewModel: OnboardingViewModelType, OnboardingViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((SplashScreenViewModelAction) -> Void)?
var callback: ((OnboardingViewModelAction) -> Void)?
// MARK: - Setup
init() {
super.init(initialViewState: SplashScreenViewState())
super.init(initialViewState: OnboardingViewState())
}
// MARK: - Public
override func process(viewAction: SplashScreenViewAction) async {
override func process(viewAction: OnboardingViewAction) async {
switch viewAction {
case .login:
callback?(.login)
@@ -17,7 +17,7 @@
import Foundation
@MainActor
protocol SplashScreenViewModelProtocol {
var callback: ((SplashScreenViewModelAction) -> Void)? { get set }
var context: SplashScreenViewModelType.Context { get }
protocol OnboardingViewModelProtocol {
var callback: ((OnboardingViewModelAction) -> Void)? { get set }
var context: OnboardingViewModelType.Context { get }
}
@@ -16,7 +16,7 @@
import SwiftUI
struct SplashScreenPageIndicator: View {
struct OnboardingPageIndicator: View {
// MARK: - Properties
// MARK: Public
@@ -16,13 +16,13 @@
import SwiftUI
struct SplashScreenPageView: View {
struct OnboardingPageView: View {
// MARK: - Properties
// MARK: Public
/// The content that this page should display.
let content: SplashScreenPageContent
let content: OnboardingPageContent
// MARK: - Views
@@ -54,11 +54,11 @@ struct SplashScreenPageView: View {
}
}
struct SplashScreenPage_Previews: PreviewProvider {
static let content = SplashScreenViewState().content
struct OnboardingPage_Previews: PreviewProvider {
static let content = OnboardingViewState().content
static var previews: some View {
ForEach(0..<content.count, id: \.self) { index in
SplashScreenPageView(content: content[index])
OnboardingPageView(content: content[index])
}
}
}
@@ -17,8 +17,8 @@
import DesignKit
import SwiftUI
/// The splash screen shown at the beginning of the onboarding flow.
struct SplashScreen: View {
/// The screen shown at the beginning of the onboarding flow.
struct OnboardingScreen: View {
// MARK: - Properties
// MARK: Private
@@ -36,7 +36,7 @@ struct SplashScreen: View {
// MARK: Public
@ObservedObject var context: SplashScreenViewModel.Context
@ObservedObject var context: OnboardingViewModel.Context
var body: some View {
GeometryReader { geometry in
@@ -47,12 +47,12 @@ struct SplashScreen: View {
// The main content of the carousel
HStack(alignment: .top, spacing: 0) {
// Add a hidden page at the start of the carousel duplicating the content of the last page
SplashScreenPageView(content: context.viewState.content[pageCount - 1])
OnboardingPageView(content: context.viewState.content[pageCount - 1])
.frame(width: geometry.size.width)
.accessibilityIdentifier("hiddenPage")
ForEach(0..<pageCount, id: \.self) { index in
SplashScreenPageView(content: context.viewState.content[index])
OnboardingPageView(content: context.viewState.content[index])
.frame(width: geometry.size.width)
}
}
@@ -60,7 +60,7 @@ struct SplashScreen: View {
Spacer()
SplashScreenPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
.frame(width: geometry.size.width)
.padding(.bottom)
@@ -213,11 +213,11 @@ struct SplashScreen: View {
// MARK: - Previews
struct SplashScreen_Previews: PreviewProvider {
static let viewModel = SplashScreenViewModel()
struct OnboardingScreen_Previews: PreviewProvider {
static let viewModel = OnboardingViewModel()
static var previews: some View {
SplashScreen(context: viewModel.context)
OnboardingScreen(context: viewModel.context)
.tint(.element.accent)
}
}
@@ -0,0 +1,32 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct InviteFriendsCoordinator: CoordinatorProtocol {
let userId: String
func toPresentable() -> AnyView {
guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: userId).absoluteString else {
return AnyView(EmptyView())
}
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
return AnyView(UIActivityViewControllerWrapper(activityItems: [shareText])
.presentationDetents([.medium])
.ignoresSafeArea())
}
}
@@ -14,6 +14,12 @@
// limitations under the License.
//
import UIKit
import SwiftUI
class SplashViewController: UIViewController { }
struct SplashScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(
Image(asset: Asset.Images.appLogo)
)
}
}
@@ -0,0 +1,28 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct UIActivityViewControllerWrapper: UIViewControllerRepresentable {
var activityItems: [Any]
var applicationActivities: [UIActivity]?
func makeUIViewController(context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) { }
}
@@ -17,49 +17,33 @@
import SwiftUI
struct RoomScreenCoordinatorParameters {
let navigationRouter: NavigationRouterType
let navigationController: NavigationController
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let roomName: String?
let roomAvatarUrl: String?
}
final class RoomScreenCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class RoomScreenCoordinator: CoordinatorProtocol {
private let parameters: RoomScreenCoordinatorParameters
private let roomScreenHostingController: UIViewController
private var roomScreenViewModel: RoomScreenViewModelProtocol
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
// MARK: - Setup
private var viewModel: RoomScreenViewModelProtocol
private var navigationController: NavigationController { parameters.navigationController }
init(parameters: RoomScreenCoordinatorParameters) {
self.parameters = parameters
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider,
roomName: parameters.roomName,
roomAvatarUrl: parameters.roomAvatarUrl)
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
roomScreenHostingController = UIHostingController(rootView: view)
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider,
roomName: parameters.roomName,
roomAvatarUrl: parameters.roomAvatarUrl)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
roomScreenViewModel.callback = { [weak self] result in
viewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.debug("RoomScreenViewModel did complete with result: \(result).")
switch result {
@@ -71,12 +55,12 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
roomScreenHostingController
}
func stop() {
roomScreenViewModel.stop()
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(RoomScreen(context: viewModel.context))
}
// MARK: - Private
@@ -84,32 +68,20 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
private func displayVideo(for videoURL: URL) {
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
let coordinator = VideoPlayerCoordinator(parameters: params)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self, let coordinator = coordinator else { return }
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
coordinator.callback = { [weak self] _ in
self?.navigationController.pop()
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.push(coordinator)
}
private func displayFile(for fileURL: URL, with title: String?) {
let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title)
let coordinator = FilePreviewCoordinator(parameters: params)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self, let coordinator = coordinator else { return }
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
coordinator.callback = { [weak self] _ in
self?.navigationController.pop()
}
navigationController.push(coordinator)
}
}
@@ -20,37 +20,22 @@ struct SessionVerificationCoordinatorParameters {
let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol
}
final class SessionVerificationCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class SessionVerificationCoordinator: CoordinatorProtocol {
private let parameters: SessionVerificationCoordinatorParameters
private let sessionVerificationHostingController: UIViewController
private var sessionVerificationViewModel: SessionVerificationViewModelProtocol
private var viewModel: SessionVerificationViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (() -> Void)?
// MARK: - Setup
init(parameters: SessionVerificationCoordinatorParameters) {
self.parameters = parameters
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
let view = SessionVerificationScreen(context: viewModel.context)
sessionVerificationViewModel = viewModel
sessionVerificationHostingController = UIHostingController(rootView: view)
viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
sessionVerificationViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
@@ -60,9 +45,7 @@ final class SessionVerificationCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
sessionVerificationHostingController
func toPresentable() -> AnyView {
AnyView(SessionVerificationScreen(context: viewModel.context))
}
func stop() { }
}
@@ -17,7 +17,8 @@
import SwiftUI
struct SettingsCoordinatorParameters {
let navigationRouter: NavigationRouterType
let navigationController: NavigationController
let userNotificationController: UserNotificationControllerProtocol
let userSession: UserSessionProtocol
let bugReportService: BugReportServiceProtocol
}
@@ -27,25 +28,10 @@ enum SettingsCoordinatorAction {
case logout
}
final class SettingsCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class SettingsCoordinator: CoordinatorProtocol {
private let parameters: SettingsCoordinatorParameters
private let settingsHostingController: UIViewController
private var settingsViewModel: SettingsViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var viewModel: SettingsViewModelProtocol
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((SettingsCoordinatorAction) -> Void)?
// MARK: - Setup
@@ -53,14 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
init(parameters: SettingsCoordinatorParameters) {
self.parameters = parameters
let viewModel = SettingsViewModel(withUserSession: parameters.userSession)
let view = SettingsScreen(context: viewModel.context)
settingsViewModel = viewModel
settingsHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: settingsHostingController)
settingsViewModel.callback = { [weak self] result in
viewModel = SettingsViewModel(withUserSession: parameters.userSession)
viewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.debug("SettingsViewModel did complete with result: \(result).")
switch result {
@@ -80,16 +60,10 @@ final class SettingsCoordinator: Coordinator, Presentable {
// MARK: - Public
func start() {
// no-op
func toPresentable() -> AnyView {
AnyView(SettingsScreen(context: viewModel.context))
}
func toPresentable() -> UIViewController {
settingsHostingController
}
func stop() { }
// MARK: - Private
private func toggleAnalytics() {
@@ -102,39 +76,25 @@ final class SettingsCoordinator: Coordinator, Presentable {
private func presentBugReportScreen() {
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
screenshot: nil)
userNotificationController: parameters.userNotificationController,
screenshot: nil,
isModallyPresented: false)
let coordinator = BugReportCoordinator(parameters: params)
coordinator.completion = { [weak self, weak coordinator] in
guard let self, let coordinator = coordinator else { return }
self.parameters.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
self.showSuccess(label: ElementL10n.done)
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator, animated: true) { [weak self] in
guard let self else { return }
self.remove(childCoordinator: coordinator)
coordinator.completion = { [weak self] result in
switch result {
case .finish:
self?.showSuccess(label: ElementL10n.done)
default:
break
}
self?.parameters.navigationController.pop()
}
parameters.navigationController.push(coordinator)
}
/// 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))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
/// Show success indicator
private func showSuccess(label: String) {
statusIndicator = indicatorPresenter.present(.success(label: label))
parameters.userNotificationController.submitNotification(UserNotification(title: label))
}
}
@@ -16,10 +16,8 @@
import SwiftUI
@available(iOS 14, *)
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState,
SettingsViewAction>
@available(iOS 14, *)
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState, SettingsViewAction>
class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
// MARK: - Properties
@@ -19,6 +19,5 @@ import Foundation
@MainActor
protocol SettingsViewModelProtocol {
var callback: ((SettingsViewModelAction) -> Void)? { get set }
@available(iOS 14, *)
var context: SettingsViewModelType.Context { get }
}
@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SplashViewController" customModule="ElementX" customModuleProvider="target">
<connections>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/app-logo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
<rect key="frame" x="87" y="328" width="240" height="240"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="ue7-fB-5XS" secondAttribute="bottom" constant="16" id="Cip-Sc-gaF"/>
<constraint firstItem="ue7-fB-5XS" firstAttribute="top" relation="greaterThanOrEqual" secondItem="fnl-2z-Ty3" secondAttribute="top" constant="16" id="Clt-cS-YAr"/>
<constraint firstItem="ue7-fB-5XS" firstAttribute="centerY" secondItem="i5M-Pr-FkT" secondAttribute="centerY" id="N3w-Jf-MRA"/>
<constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="ue7-fB-5XS" secondAttribute="trailing" constant="16" id="WfN-3K-kpr"/>
<constraint firstItem="ue7-fB-5XS" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="ujr-SL-AyX"/>
<constraint firstItem="ue7-fB-5XS" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="fnl-2z-Ty3" secondAttribute="leading" constant="16" id="yNl-Wu-AES"/>
</constraints>
<point key="canvasLocation" x="137.68115942028987" y="153.34821428571428"/>
</view>
</objects>
<resources>
<image name="Images/app-logo" width="240" height="240"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -1,81 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
final class SplashScreenCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let splashScreenHostingController: UIViewController
private var splashScreenViewModel: SplashScreenViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((SplashScreenCoordinatorAction) -> Void)?
// MARK: - Setup
init() {
let viewModel = SplashScreenViewModel()
let view = SplashScreen(context: viewModel.context)
splashScreenViewModel = viewModel
splashScreenHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: splashScreenHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
splashScreenViewModel.callback = { [weak self] action in
MXLog.debug("SplashScreenViewModel did complete with result: \(action).")
guard let self else { return }
switch action {
case .login:
self.callback?(.login)
}
}
}
func toPresentable() -> UIViewController {
splashScreenHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -25,45 +25,24 @@ enum VideoPlayerCoordinatorAction {
case cancel
}
final class VideoPlayerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class VideoPlayerCoordinator: CoordinatorProtocol {
private let parameters: VideoPlayerCoordinatorParameters
private let videoPlayerHostingController: UIViewController
private var videoPlayerViewModel: VideoPlayerViewModelProtocol
private var viewModel: VideoPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((VideoPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: VideoPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
let view = VideoPlayerScreen(context: viewModel.context)
videoPlayerViewModel = viewModel
videoPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController)
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
configureAudioSession(.sharedInstance())
videoPlayerViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
switch action {
@@ -73,16 +52,12 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
videoPlayerHostingController
func toPresentable() -> AnyView {
AnyView(VideoPlayerScreen(context: viewModel.context))
}
func stop() {
stopLoading()
}
// MARK: - Private
private func configureAudioSession(_ session: AVAudioSession) {
do {
try session.setCategory(.playback,
@@ -93,17 +68,4 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
MXLog.debug("Configure audio session failed: \(error)")
}
}
/// 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) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
}
}