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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 1627 additions and 3720 deletions

View File

@ -16,6 +16,6 @@ jobs:
key: danger-swift-cache-key key: danger-swift-cache-key
- name: Danger - name: Danger
uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.12.3 uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.14.2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

File diff suppressed because it is too large Load Diff

View File

@ -111,7 +111,7 @@
{ {
"identity" : "swift-snapshot-testing", "identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing", "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git",
"state" : { "state" : {
"revision" : "f29e2014f6230cf7d5138fc899da51c7f513d467", "revision" : "f29e2014f6230cf7d5138fc899da51c7f513d467",
"version" : "1.10.0" "version" : "1.10.0"
@ -129,7 +129,7 @@
{ {
"identity" : "swiftui-introspect", "identity" : "swiftui-introspect",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect", "location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : { "state" : {
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24", "revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
"version" : "0.1.4" "version" : "0.1.4"

View File

@ -16,7 +16,7 @@
import Combine import Combine
import MatrixRustSDK import MatrixRustSDK
import UIKit import SwiftUI
struct ServiceLocator { struct ServiceLocator {
fileprivate static var serviceLocator: ServiceLocator? fileprivate static var serviceLocator: ServiceLocator?
@ -28,19 +28,12 @@ struct ServiceLocator {
return serviceLocator return serviceLocator
} }
let userIndicatorPresenter: UserIndicatorTypePresenter let userNotificationController: UserNotificationControllerProtocol
} }
class AppCoordinator: Coordinator { class AppCoordinator: CoordinatorProtocol {
private let window: UIWindow
private let stateMachine: AppCoordinatorStateMachine private let stateMachine: AppCoordinatorStateMachine
private let navigationController: NavigationController
private let mainNavigationController: UINavigationController
private let splashViewController: UIViewController
private let navigationRouter: NavigationRouter
private let userSessionStore: UserSessionStoreProtocol private let userSessionStore: UserSessionStoreProtocol
private var userSession: UserSessionProtocol! { private var userSession: UserSessionProtocol! {
@ -53,34 +46,22 @@ class AppCoordinator: Coordinator {
} }
private var userSessionFlowCoordinator: UserSessionFlowCoordinator? private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var authenticationCoordinator: AuthenticationCoordinator?
private let bugReportService: BugReportServiceProtocol private let bugReportService: BugReportServiceProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
var childCoordinators: [Coordinator] = []
init() { init() {
navigationController = NavigationController()
stateMachine = AppCoordinatorStateMachine() stateMachine = AppCoordinatorStateMachine()
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL) bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
splashViewController = SplashViewController() navigationController.setRootCoordinator(SplashScreenCoordinator())
mainNavigationController = ElementNavigationController(rootViewController: splashViewController) ServiceLocator.serviceLocator = ServiceLocator(userNotificationController: UserNotificationController(rootCoordinator: navigationController))
mainNavigationController.navigationBar.prefersLargeTitles = true
window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = mainNavigationController
window.tintColor = .element.accent
navigationRouter = NavigationRouter(navigationController: mainNavigationController)
ServiceLocator.serviceLocator = ServiceLocator(userIndicatorPresenter: UserIndicatorTypePresenter(presentingViewController: mainNavigationController))
guard let bundleIdentifier = Bundle.main.bundleIdentifier else { guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
fatalError("Should have a valid bundle identifier at this point") fatalError("Should have a valid bundle identifier at this point")
@ -95,11 +76,12 @@ class AppCoordinator: Coordinator {
setupLogging() setupLogging()
Bundle.elementFallbackLanguage = "en"
// Benchmark.trackingEnabled = true // Benchmark.trackingEnabled = true
} }
func start() { func start() {
window.makeKeyAndVisible()
stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication) stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication)
} }
@ -107,6 +89,10 @@ class AppCoordinator: Coordinator {
hideLoadingIndicator() hideLoadingIndicator()
} }
func toPresentable() -> AnyView {
ServiceLocator.shared.userNotificationController.toPresentable()
}
// MARK: - Private // MARK: - Private
private func setupLogging() { private func setupLogging() {
@ -192,12 +178,11 @@ class AppCoordinator: Coordinator {
private func startAuthentication() { private func startAuthentication() {
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore) let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
let coordinator = AuthenticationCoordinator(authenticationService: authenticationService, authenticationCoordinator = AuthenticationCoordinator(authenticationService: authenticationService,
navigationRouter: navigationRouter) navigationController: navigationController)
coordinator.delegate = self authenticationCoordinator?.delegate = self
add(childCoordinator: coordinator) authenticationCoordinator?.start()
coordinator.start()
} }
private func startAuthenticationSoftLogout() { private func startAuthenticationSoftLogout() {
@ -223,27 +208,22 @@ class AppCoordinator: Coordinator {
switch result { switch result {
case .signedIn(let session): case .signedIn(let session):
self.userSession = session self.userSession = session
self.remove(childCoordinator: coordinator)
self.stateMachine.processEvent(.succeededSigningIn) self.stateMachine.processEvent(.succeededSigningIn)
case .clearAllData: case .clearAllData:
// clear user data // clear user data
self.userSessionStore.logout(userSession: self.userSession) self.userSessionStore.logout(userSession: self.userSession)
self.userSession = nil self.userSession = nil
self.remove(childCoordinator: coordinator)
self.startAuthentication() self.startAuthentication()
} }
} }
add(childCoordinator: coordinator) navigationController.setRootCoordinator(coordinator)
coordinator.start()
navigationRouter.setRootModule(coordinator)
} }
} }
private func setupUserSession() { private func setupUserSession() {
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
navigationRouter: navigationRouter, navigationController: navigationController,
bugReportService: bugReportService) bugReportService: bugReportService)
userSessionFlowCoordinator.callback = { [weak self] action in userSessionFlowCoordinator.callback = { [weak self] action in
@ -280,11 +260,7 @@ class AppCoordinator: Coordinator {
} }
private func presentSplashScreen(isSoftLogout: Bool = false) { private func presentSplashScreen(isSoftLogout: Bool = false) {
if let presentedCoordinator = childCoordinators.first { navigationController.setRootCoordinator(SplashScreenCoordinator())
remove(childCoordinator: presentedCoordinator)
}
mainNavigationController.setViewControllers([splashViewController], animated: false)
if isSoftLogout { if isSoftLogout {
startAuthenticationSoftLogout() startAuthenticationSoftLogout()
@ -319,16 +295,21 @@ class AppCoordinator: Coordinator {
// MARK: Toasts and loading indicators // MARK: Toasts and loading indicators
static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
private func showLoadingIndicator() { private func showLoadingIndicator() {
loadingIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true)) ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.loading,
persistent: true))
} }
private func hideLoadingIndicator() { private func hideLoadingIndicator() {
loadingIndicator = nil ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
} }
private func showLoginErrorToast() { private func showLoginErrorToast() {
statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging in")) ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: "Failed logging in"))
} }
} }
@ -337,7 +318,6 @@ class AppCoordinator: Coordinator {
extension AppCoordinator: AuthenticationCoordinatorDelegate { extension AppCoordinator: AuthenticationCoordinatorDelegate {
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) { func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession self.userSession = userSession
remove(childCoordinator: authenticationCoordinator)
stateMachine.processEvent(.succeededSigningIn) stateMachine.processEvent(.succeededSigningIn)
} }
} }

View File

@ -14,26 +14,38 @@
// limitations under the License. // limitations under the License.
// //
import UIKit import SwiftUI
@main @main
class AppDelegate: UIResponder, UIApplicationDelegate { struct Application: App {
private lazy var appCoordinator: Coordinator = Tests.isRunningUITests ? UITestsAppCoordinator() : AppCoordinator() @UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
private let applicationCoordinator: CoordinatorProtocol
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { init() {
// use `en` as fallback language if Tests.isRunningUITests {
Bundle.elementFallbackLanguage = "en" applicationCoordinator = UITestsAppCoordinator()
} else {
return true applicationCoordinator = AppCoordinator()
}
} }
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { var body: some Scene {
WindowGroup {
if Tests.isRunningUnitTests { if Tests.isRunningUnitTests {
return true EmptyView()
} else {
applicationCoordinator.toPresentable()
.tint(.element.accent)
.task {
applicationCoordinator.start()
}
}
}
}
} }
appCoordinator.start() class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
return true true
} }
} }

View File

@ -0,0 +1,34 @@
//
// 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
@MainActor
protocol CoordinatorProtocol {
func start()
func stop()
func toPresentable() -> AnyView
}
extension CoordinatorProtocol {
func start() { }
func stop() { }
func toPresentable() -> AnyView {
AnyView(Text("View not configured"))
}
}

View File

@ -0,0 +1,186 @@
//
// 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
class NavigationController: ObservableObject, CoordinatorProtocol {
private var dismissalCallbacks = [UUID: () -> Void]()
@Published fileprivate var internalRootCoordinator: AnyCoordinator? {
didSet {
if let oldValue {
oldValue.coordinator.stop()
}
if let internalRootCoordinator {
logPresentationChange("Set root", internalRootCoordinator)
internalRootCoordinator.coordinator.start()
}
}
}
@Published fileprivate var internalSheetCoordinator: AnyCoordinator? {
didSet {
if let oldValue {
logPresentationChange("Dismiss", oldValue)
oldValue.coordinator.stop()
dismissalCallbacks[oldValue.id]?()
dismissalCallbacks.removeValue(forKey: oldValue.id)
}
if let internalSheetCoordinator {
logPresentationChange("Present", internalSheetCoordinator)
internalSheetCoordinator.coordinator.start()
}
}
}
@Published fileprivate var internalNavigationStack = [AnyCoordinator]() {
didSet {
let diffs = internalNavigationStack.difference(from: oldValue)
diffs.forEach { change in
switch change {
case .insert(_, let anyCoordinator, _):
logPresentationChange("Push", anyCoordinator)
anyCoordinator.coordinator.start()
case .remove(_, let anyCoordinator, _):
logPresentationChange("Pop", anyCoordinator)
anyCoordinator.coordinator.stop()
dismissalCallbacks[anyCoordinator.id]?()
dismissalCallbacks.removeValue(forKey: anyCoordinator.id)
}
}
}
}
var rootCoordinator: CoordinatorProtocol? {
internalRootCoordinator?.coordinator
}
var coordinators: [CoordinatorProtocol] {
internalNavigationStack.map(\.coordinator)
}
var sheetCoordinator: CoordinatorProtocol? {
internalSheetCoordinator?.coordinator
}
func setRootCoordinator(_ coordinator: any CoordinatorProtocol) {
popToRoot(animated: false)
internalRootCoordinator = AnyCoordinator(coordinator)
}
func push(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
let anyCoordinator = AnyCoordinator(coordinator)
if let dismissalCallback {
dismissalCallbacks[anyCoordinator.id] = dismissalCallback
}
internalNavigationStack.append(anyCoordinator)
}
func popToRoot(animated: Bool = true) {
dismissSheet()
guard !internalNavigationStack.isEmpty else {
return
}
if !animated {
// Disabling animations doesn't work through normal Transactions
// https://stackoverflow.com/questions/72832243
UIView.setAnimationsEnabled(false)
}
internalNavigationStack.removeAll()
if !animated {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
UIView.setAnimationsEnabled(true)
}
}
}
func pop() {
dismissSheet()
internalNavigationStack.removeLast()
}
func presentSheet(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
let anyCoordinator = AnyCoordinator(coordinator)
if let dismissalCallback {
dismissalCallbacks[anyCoordinator.id] = dismissalCallback
}
internalSheetCoordinator = anyCoordinator
}
func dismissSheet() {
internalSheetCoordinator = nil
}
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationControllerView(navigationController: self))
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ anyCoordinator: AnyCoordinator) {
if let navigationCoordinator = anyCoordinator.coordinator as? NavigationController, let rootCoordinator = navigationCoordinator.rootCoordinator {
MXLog.info("\(change): NavigationController(\(anyCoordinator.id)) - \(rootCoordinator)")
} else {
MXLog.info("\(change): \(anyCoordinator.coordinator)(\(anyCoordinator.id))")
}
}
}
private struct NavigationControllerView: View {
@ObservedObject var navigationController: NavigationController
var body: some View {
NavigationStack(path: $navigationController.internalNavigationStack) {
navigationController.internalRootCoordinator?.coordinator.toPresentable()
.navigationDestination(for: AnyCoordinator.self) { anyCoordinator in
anyCoordinator.coordinator.toPresentable()
}
}
.sheet(item: $navigationController.internalSheetCoordinator) { anyCoordinator in
anyCoordinator.coordinator.toPresentable()
}
}
}
private struct AnyCoordinator: Identifiable, Hashable {
let id = UUID()
let coordinator: any CoordinatorProtocol
init(_ coordinator: any CoordinatorProtocol) {
self.coordinator = coordinator
}
static func == (lhs: AnyCoordinator, rhs: AnyCoordinator) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -25,11 +25,11 @@ internal enum Asset {
internal enum Images { internal enum Images {
internal static let analyticsCheckmark = ImageAsset(name: "Images/AnalyticsCheckmark") internal static let analyticsCheckmark = ImageAsset(name: "Images/AnalyticsCheckmark")
internal static let analyticsLogo = ImageAsset(name: "Images/AnalyticsLogo") internal static let analyticsLogo = ImageAsset(name: "Images/AnalyticsLogo")
internal static let onboardingScreenPage1 = ImageAsset(name: "Images/Onboarding Screen Page 1")
internal static let onboardingScreenPage2 = ImageAsset(name: "Images/Onboarding Screen Page 2")
internal static let onboardingScreenPage3 = ImageAsset(name: "Images/Onboarding Screen Page 3")
internal static let onboardingScreenPage4 = ImageAsset(name: "Images/Onboarding Screen Page 4")
internal static let serverSelectionIcon = ImageAsset(name: "Images/Server Selection Icon") internal static let serverSelectionIcon = ImageAsset(name: "Images/Server Selection Icon")
internal static let splashScreenPage1 = ImageAsset(name: "Images/Splash Screen Page 1")
internal static let splashScreenPage2 = ImageAsset(name: "Images/Splash Screen Page 2")
internal static let splashScreenPage3 = ImageAsset(name: "Images/Splash Screen Page 3")
internal static let splashScreenPage4 = ImageAsset(name: "Images/Splash Screen Page 4")
internal static let encryptionNormal = ImageAsset(name: "Images/encryption_normal") internal static let encryptionNormal = ImageAsset(name: "Images/encryption_normal")
internal static let encryptionTrusted = ImageAsset(name: "Images/encryption_trusted") internal static let encryptionTrusted = ImageAsset(name: "Images/encryption_trusted")
internal static let encryptionWarning = ImageAsset(name: "Images/encryption_warning") internal static let encryptionWarning = ImageAsset(name: "Images/encryption_warning")

View File

@ -1,54 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol describing a [Coordinator](http://khanlou.com/2015/10/coordinators-redux/).
/// Coordinators are the objects which control the navigation flow of the application.
/// It helps to isolate and reuse view controllers and pass dependencies down the navigation hierarchy.
@MainActor
protocol Coordinator: AnyObject {
/// Starts job of the coordinator.
func start()
/// Child coordinators to retain. Prevent them from getting deallocated.
var childCoordinators: [Coordinator] { get set }
/// Stores coordinator to the `childCoordinators` array.
///
/// - Parameter childCoordinator: Child coordinator to store.
func add(childCoordinator: Coordinator)
/// Remove coordinator from the `childCoordinators` array.
///
/// - Parameter childCoordinator: Child coordinator to remove.
func remove(childCoordinator: Coordinator)
/// Stops job of the coordinator. Can be used to clear some resources. Will be automatically called when the coordinator removed.
func stop()
}
// `Coordinator` default implementation
extension Coordinator {
func add(childCoordinator coordinator: Coordinator) {
childCoordinators.append(coordinator)
}
func remove(childCoordinator: Coordinator) {
childCoordinator.stop()
childCoordinators = childCoordinators.filter { $0 !== childCoordinator }
}
}

View File

@ -1,397 +0,0 @@
/*
Copyright 2019 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 UIKit
/// `NavigationRouter` is a concrete implementation of NavigationRouterType.
final class NavigationRouter: NSObject, NavigationRouterType {
// MARK: - Properties
// MARK: Private
private var completions: [UIViewController: () -> Void]
private let navigationController: UINavigationController
/// Stores the association between the added Presentable and his view controller.
/// They can be the same if the controller is not added via his Coordinator or it is a simple UIViewController.
private var storedModules = WeakDictionary<UIViewController, AnyObject>()
// MARK: Public
/// Returns the presentables associated to each view controller
var modules: [Presentable] {
viewControllers.map { viewController -> Presentable in
self.module(for: viewController)
}
}
/// Return the view controllers stack
var viewControllers: [UIViewController] {
navigationController.viewControllers
}
// MARK: - Setup
init(navigationController: UINavigationController) {
self.navigationController = navigationController
completions = [:]
super.init()
self.navigationController.delegate = self
// Post local notification on NavigationRouter creation
let userInfo: [String: Any] = [NavigationRouter.NotificationUserInfoKey.navigationRouter: self,
NavigationRouter.NotificationUserInfoKey.navigationController: navigationController]
NotificationCenter.default.post(name: NavigationRouter.didCreate, object: self, userInfo: userInfo)
}
deinit {
// Post local notification on NavigationRouter deinit
let userInfo: [String: Any] = [NavigationRouter.NotificationUserInfoKey.navigationRouter: self,
NavigationRouter.NotificationUserInfoKey.navigationController: navigationController]
NotificationCenter.default.post(name: NavigationRouter.willDestroy, object: self, userInfo: userInfo)
}
// MARK: - Public
func present(_ module: Presentable, animated: Bool = true) {
MXLog.debug("Present \(module)")
navigationController.present(module.toPresentable(), animated: animated, completion: nil)
}
func dismissModule(animated: Bool = true, completion: (() -> Void)? = nil) {
MXLog.debug("Dismiss presented module")
navigationController.dismiss(animated: animated, completion: completion)
}
func setRootModule(_ module: Presentable, hideNavigationBar: Bool = false, animated: Bool = false, popCompletion: (() -> Void)? = nil) {
MXLog.debug("Set root module \(module)")
let controller = module.toPresentable()
// Avoid setting a UINavigationController onto stack
guard controller is UINavigationController == false else {
MXLog.error("Cannot add a UINavigationController to NavigationRouter")
return
}
addModule(module, for: controller)
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
if let popCompletion {
completions[controller] = popCompletion
}
willPushViewController(controller)
navigationController.setViewControllers([controller], animated: animated)
navigationController.isNavigationBarHidden = hideNavigationBar
// Pop old view controllers
controllersToPop.forEach {
self.didPopViewController($0)
}
// Add again controller to module association, in case same module instance is added back
addModule(module, for: controller)
didPushViewController(controller)
}
func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool) {
MXLog.debug("Set modules \(modules)")
let controllers = modules.map { module -> UIViewController in
let controller = module.presentable.toPresentable()
self.addModule(module.presentable, for: controller)
return controller
}
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
controllers.forEach {
self.willPushViewController($0)
}
// Set new view controllers
navigationController.setViewControllers(controllers, animated: animated)
navigationController.isNavigationBarHidden = hideNavigationBar
// Pop old view controllers
controllersToPop.forEach {
self.didPopViewController($0)
}
// Add again controller to module association, in case same modules instance are added back
modules.forEach { module in
self.addModule(module.presentable, for: module.presentable.toPresentable())
}
controllers.forEach {
self.didPushViewController($0)
}
}
func popToRootModule(animated: Bool) {
MXLog.debug("Pop to root module")
let controllers = navigationController.viewControllers
if controllers.count > 1 {
let controllersToPop = controllers[1..<controllers.count]
controllersToPop.reversed().forEach {
self.willPopViewController($0)
}
}
if let controllers = navigationController.popToRootViewController(animated: animated) {
controllers.reversed().forEach {
self.didPopViewController($0)
}
}
}
func popToModule(_ module: Presentable, animated: Bool) {
MXLog.debug("Pop to module \(module)")
let controller = module.toPresentable()
let controllersBeforePop = navigationController.viewControllers
if let controllerIndex = controllersBeforePop.firstIndex(of: controller) {
let controllersToPop = controllersBeforePop[controllerIndex..<controllersBeforePop.count]
controllersToPop.reversed().forEach {
self.willPopViewController($0)
}
}
if let controllers = navigationController.popToViewController(controller, animated: animated) {
controllers.reversed().forEach {
self.didPopViewController($0)
}
}
}
func push(_ module: Presentable, animated: Bool = true, popCompletion: (() -> Void)? = nil) {
MXLog.debug("Push module \(module)")
let controller = module.toPresentable()
// Avoid pushing UINavigationController onto stack
guard controller is UINavigationController == false else {
MXLog.error("Cannot push a UINavigationController to NavigationRouter")
return
}
addModule(module, for: controller)
if let completion = popCompletion {
completions[controller] = completion
}
willPushViewController(controller)
navigationController.pushViewController(controller, animated: animated)
didPushViewController(controller)
}
func push(_ modules: [NavigationModule], animated: Bool) {
MXLog.debug("Push modules \(modules)")
// Avoid pushing any UINavigationController onto stack
guard modules.first(where: { $0.presentable.toPresentable() is UINavigationController }) == nil else {
MXLog.error("Cannot push a UINavigationController to NavigationRouter")
return
}
for module in modules {
let controller = module.presentable.toPresentable()
addModule(module.presentable, for: controller)
if let completion = module.popCompletion {
completions[controller] = completion
}
willPushViewController(controller)
}
var viewControllers = navigationController.viewControllers
viewControllers.append(contentsOf: modules.map { $0.presentable.toPresentable() })
navigationController.setViewControllers(viewControllers, animated: animated)
for module in modules {
let controller = module.presentable.toPresentable()
didPushViewController(controller)
}
}
func popModule(animated: Bool = true) {
MXLog.debug("Pop module")
if let lastController = navigationController.viewControllers.last {
willPopViewController(lastController)
}
if let controller = navigationController.popViewController(animated: animated) {
didPopViewController(controller)
}
}
func popAllModules(animated: Bool) {
MXLog.debug("Pop all modules")
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
navigationController.setViewControllers([], animated: animated)
controllersToPop.forEach {
self.didPopViewController($0)
}
}
func contains(_ module: Presentable) -> Bool {
let controller = module.toPresentable()
return navigationController.viewControllers.contains(controller)
}
// MARK: Presentable
func toPresentable() -> UIViewController {
navigationController
}
// MARK: - Private
private func module(for viewController: UIViewController) -> Presentable {
guard let module = storedModules[viewController] as? Presentable else {
return viewController
}
return module
}
private func addModule(_ module: Presentable, for viewController: UIViewController) {
storedModules[viewController] = module as AnyObject
}
private func removeModule(for viewController: UIViewController) {
storedModules[viewController] = nil
}
private func runCompletion(for controller: UIViewController) {
guard let completion = completions[controller] else {
return
}
completion()
completions.removeValue(forKey: controller)
}
private func willPushViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.willPushModule, for: viewController)
}
private func didPushViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.didPushModule, for: viewController)
}
private func willPopViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.willPopModule, for: viewController)
}
private func didPopViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.didPopModule, for: viewController)
// Call completion closure associated to the view controller
// So associated coordinator can be deallocated
runCompletion(for: viewController)
removeModule(for: viewController)
}
private func postNotification(withName name: Notification.Name, for viewController: UIViewController) {
let module = module(for: viewController)
let userInfo: [String: Any] = [
NotificationUserInfoKey.navigationRouter: self,
NotificationUserInfoKey.module: module,
NotificationUserInfoKey.viewController: viewController
]
NotificationCenter.default.post(name: name, object: self, userInfo: userInfo)
}
}
// MARK: - UINavigationControllerDelegate
extension NavigationRouter: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// Try to post `NavigationRouter.willPopModule` notification here
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
// Ensure the view controller is popping
guard let poppedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from),
!navigationController.viewControllers.contains(poppedViewController) else {
return
}
MXLog.debug("Popped module: \(poppedViewController)")
didPopViewController(poppedViewController)
}
}
// MARK: - NavigationRouter notification constants
extension NavigationRouter {
// MARK: Notification names
public static let willPushModule = Notification.Name("NavigationRouterWillPushModule")
public static let didPushModule = Notification.Name("NavigationRouterDidPushModule")
public static let willPopModule = Notification.Name("NavigationRouterWillPopModule")
public static let didPopModule = Notification.Name("NavigationRouterDidPopModule")
public static let didCreate = Notification.Name("NavigationRouterDidCreate")
public static let willDestroy = Notification.Name("NavigationRouterWillDestroy")
// MARK: Notification keys
public enum NotificationUserInfoKey {
/// The associated view controller (UIViewController).
static let viewController = "viewController"
/// The associated module (Presentable), can the view controller itself or is Coordinator
static let module = "module"
/// The navigation router that send the notification (NavigationRouterType)
static let navigationRouter = "navigationRouter"
/// The navigation controller (UINavigationController) associated to the navigation router
static let navigationController = "navigationController"
}
}

View File

@ -1,93 +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 UIKit
/// `NavigationRouterStore` enables to get a NavigationRouter from a UINavigationController instance.
class NavigationRouterStore: NavigationRouterStoreProtocol {
// MARK: - Constants
static let shared = NavigationRouterStore()
// MARK: - Properties
// WeakDictionary does not work with protocol
// Find a way to use NavigationRouterType as value
private var navigationRouters = WeakDictionary<UINavigationController, NavigationRouter>()
// MARK: - Setup
/// As we are ensuring that there is only one navigation controller per NavigationRouter, the class here should be used as a singleton.
private init() {
registerNavigationRouterNotifications()
}
// MARK: - Public
func navigationRouter(for navigationController: UINavigationController) -> NavigationRouterType {
if let existingNavigationRouter = findNavigationRouter(for: navigationController) {
return existingNavigationRouter
}
let navigationRouter = NavigationRouter(navigationController: ElementNavigationController())
return navigationRouter
}
// MARK: - Private
private func findNavigationRouter(for navigationController: UINavigationController) -> NavigationRouterType? {
navigationRouters[navigationController]
}
private func removeNavigationRouter(for navigationController: UINavigationController) {
navigationRouters[navigationController] = nil
}
private func registerNavigationRouterNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(navigationRouterDidCreate(_:)), name: NavigationRouter.didCreate, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(navigationRouterWillDestroy(_:)), name: NavigationRouter.willDestroy, object: nil)
}
@objc private func navigationRouterDidCreate(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let navigationRouter = userInfo[NavigationRouter.NotificationUserInfoKey.navigationRouter] as? NavigationRouterType,
let navigationController = userInfo[NavigationRouter.NotificationUserInfoKey.navigationController] as? UINavigationController else {
return
}
if let existingNavigationRouter = findNavigationRouter(for: navigationController) {
fatalError("\(existingNavigationRouter) is already tied to the same navigation controller as \(navigationRouter). We should have only one NavigationRouter per navigation controller")
} else {
// WeakDictionary does not work with protocol
// Find a way to avoid this cast
navigationRouters[navigationController] = navigationRouter as? NavigationRouter
}
}
@objc private func navigationRouterWillDestroy(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let navigationRouter = userInfo[NavigationRouter.NotificationUserInfoKey.navigationRouter] as? NavigationRouterType,
let navigationController = userInfo[NavigationRouter.NotificationUserInfoKey.navigationController] as? UINavigationController else {
return
}
if let existingNavigationRouter = findNavigationRouter(for: navigationController), existingNavigationRouter !== navigationRouter {
fatalError("\(existingNavigationRouter) is already tied to the same navigation controller as \(navigationRouter). We should have only one NavigationRouter per navigation controller")
}
removeNavigationRouter(for: navigationController)
}
}

View File

@ -1,25 +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 UIKit
/// `NavigationRouterStoreProtocol` describes a structure that enables to get a NavigationRouter from a UINavigationController instance.
@MainActor
protocol NavigationRouterStoreProtocol {
/// Gets the existing navigation router for the supplied controller, creating a new one if it doesn't yet exist.
/// Note: The store only holds a weak reference to the returned router. It is the caller's responsibility to retain it.
func navigationRouter(for navigationController: UINavigationController) -> NavigationRouterType
}

View File

@ -1,143 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol describing a router that wraps a UINavigationController and add convenient completion handlers. Completions are called when a Presentable is removed.
/// Routers are used to be passed between coordinators. They handles only `physical` navigation.
@MainActor
protocol NavigationRouterType: AnyObject, Presentable {
/// Present modally a view controller on the navigation controller
///
/// - Parameter module: The Presentable to present.
/// - Parameter animated: Specify true to animate the transition.
func present(_ module: Presentable, animated: Bool)
/// Dismiss presented view controller from navigation controller
///
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter completion: Animation completion (not the pop completion).
func dismissModule(animated: Bool, completion: (() -> Void)?)
/// Set root view controller of navigation controller
///
/// - Parameter module: The Presentable to set as root.
/// - Parameter hideNavigationBar: Specify true to hide the UINavigationBar.
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter popCompletion: Completion called when `module` is removed from the navigation stack.
func setRootModule(_ module: Presentable, hideNavigationBar: Bool, animated: Bool, popCompletion: (() -> Void)?)
/// Set view controllers stack of navigation controller
/// - Parameters:
/// - modules: The modules stack to set.
/// - hideNavigationBar: Specify true to hide the UINavigationBar.
/// - animated: Specify true to animate the transition.
func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool)
/// Pop to root view controller of navigation controller and remove all others
///
/// - Parameter animated: Specify true to animate the transition.
func popToRootModule(animated: Bool)
/// Pops view controllers until the specified view controller is at the top of the navigation stack
///
/// - Parameter module: The Presentable that should to be at the top of the stack.
/// - Parameter animated: Specify true to animate the transition.
func popToModule(_ module: Presentable, animated: Bool)
/// Push a view controller on navigation controller stack
///
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter popCompletion: Completion called when `module` is removed from the navigation stack.
func push(_ module: Presentable, animated: Bool, popCompletion: (() -> Void)?)
/// Push some view controllers on navigation controller stack
///
/// - Parameter modules: Modules to push
/// - Parameter animated: Specify true to animate the transition.
func push(_ modules: [NavigationModule], animated: Bool)
/// Pop last view controller from navigation controller stack
///
/// - Parameter animated: Specify true to animate the transition.
func popModule(animated: Bool)
/// Pops all view controllers
///
/// - Parameter animated: Specify true to animate the transition.
func popAllModules(animated: Bool)
/// Returns the modules that are currently in the navigation stack
var modules: [Presentable] { get }
/// Check if the navigation controller contains the given presentable.
/// - Parameter module: The presentable for which to check the existence.
func contains(_ module: Presentable) -> Bool
}
// `NavigationRouterType` default implementation
extension NavigationRouterType {
func setRootModule(_ module: Presentable) {
setRootModule(module, hideNavigationBar: false, animated: false, popCompletion: nil)
}
func setRootModule(_ module: Presentable, popCompletion: (() -> Void)?) {
setRootModule(module, hideNavigationBar: false, animated: false, popCompletion: popCompletion)
}
func setModules(_ modules: [NavigationModule], animated: Bool) {
setModules(modules, hideNavigationBar: false, animated: animated)
}
func setModules(_ modules: [Presentable], animated: Bool) {
setModules(modules, hideNavigationBar: false, animated: animated)
}
}
// MARK: - Presentable <--> NavigationModule Transitive Methods
extension NavigationRouterType {
func setRootModule(_ module: NavigationModule) {
setRootModule(module.presentable, popCompletion: module.popCompletion)
}
func push(_ module: NavigationModule, animated: Bool) {
push(module.presentable, animated: animated, popCompletion: module.popCompletion)
}
func setModules(_ modules: [Presentable], hideNavigationBar: Bool, animated: Bool) {
setModules(modules.map { $0.toModule() },
hideNavigationBar: hideNavigationBar,
animated: animated)
}
func push(_ modules: [Presentable], animated: Bool) {
push(modules.map { $0.toModule() },
animated: animated)
}
func dismissModule(animated: Bool = true, completion: (() -> Void)? = nil) {
dismissModule(animated: animated, completion: completion)
}
func push(_ module: Presentable, animated: Bool = true, popCompletion: (() -> Void)? = nil) {
push(module, animated: animated, popCompletion: popCompletion)
}
func present(_ module: Presentable, animated: Bool = true) {
present(module, animated: animated)
}
}

View File

@ -1,37 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol used to pass UIViewControllers to routers
@MainActor
protocol Presentable {
func toPresentable() -> UIViewController
}
extension UIViewController: Presentable {
public func toPresentable() -> UIViewController {
self
}
}
extension Presentable {
/// Returns a new module from the presentable without a pop completion block
/// - Returns: Module
func toModule() -> NavigationModule {
NavigationModule(presentable: self, popCompletion: nil)
}
}

View File

@ -1,85 +0,0 @@
/*
Copyright 2020 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 UIKit
/// `RootRouter` is a concrete implementation of RootRouterType.
final class RootRouter: RootRouterType {
// MARK: - Constants
// `rootViewController` animation constants
private enum RootViewControllerUpdateAnimation {
static let duration: TimeInterval = 0.3
static let options: UIView.AnimationOptions = .transitionCrossDissolve
}
// MARK: - Properties
private var presentedModule: Presentable?
let window: UIWindow
/// The root view controller currently presented
var rootViewController: UIViewController? {
window.rootViewController
}
// MARK: - Setup
init(window: UIWindow) {
self.window = window
}
// MARK: - Public methods
func setRootModule(_ module: Presentable) {
updateRootViewController(rootViewController: module.toPresentable(), animated: false, completion: nil)
window.makeKeyAndVisible()
}
func dismissRootModule(animated: Bool, completion: (() -> Void)?) {
updateRootViewController(rootViewController: nil, animated: animated, completion: completion)
}
func presentModule(_ module: Presentable, animated: Bool, completion: (() -> Void)?) {
let viewControllerPresenter = rootViewController?.presentedViewController ?? rootViewController
viewControllerPresenter?.present(module.toPresentable(), animated: animated, completion: completion)
presentedModule = module
}
func dismissModule(animated: Bool, completion: (() -> Void)?) {
presentedModule?.toPresentable().dismiss(animated: animated, completion: completion)
}
// MARK: - Private methods
private func updateRootViewController(rootViewController: UIViewController?, animated: Bool, completion: (() -> Void)?) {
if animated {
UIView.transition(with: window, duration: RootViewControllerUpdateAnimation.duration, options: RootViewControllerUpdateAnimation.options, animations: {
let oldState: Bool = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
self.window.rootViewController = rootViewController
UIView.setAnimationsEnabled(oldState)
}, completion: { _ in
completion?()
})
} else {
window.rootViewController = rootViewController
completion?()
}
}
}

View File

@ -1,49 +0,0 @@
/*
Copyright 2020 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 UIKit
/// Protocol describing a router that wraps the root navigation of the application.
/// Routers are used to be passed between coordinators. They handles only `physical` navigation.
@MainActor
protocol RootRouterType: AnyObject {
/// Update the root view controller
///
/// - Parameter module: The new root view controller to set
func setRootModule(_ module: Presentable)
/// Dismiss the root view controller
///
/// - Parameters:
/// - animated: Specify true to animate the transition.
/// - completion: The closure executed after the view controller is dismissed.
func dismissRootModule(animated: Bool, completion: (() -> Void)?)
/// Present modally a view controller on the root view controller
///
/// - Parameters:
/// - module: Specify true to animate the transition.
/// - animated: Specify true to animate the transition.
/// - completion: Animation completion.
func presentModule(_ module: Presentable, animated: Bool, completion: (() -> Void)?)
/// Dismiss modally presented view controller from root view controller
///
/// - Parameters:
/// - animated: Specify true to animate the transition.
/// - completion: Animation completion.
func dismissModule(animated: Bool, completion: (() -> Void)?)
}

View File

@ -17,7 +17,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public extension Animation { public extension Animation {
/// Animation to be used to disable animations. /// Animation to be used to disable animations.
static let noAnimation: Animation = .linear(duration: 0) static let noAnimation: Animation = .linear(duration: 0)
@ -28,7 +27,6 @@ public extension Animation {
} }
} }
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
/// Returns the result of recomputing the view's body with the provided /// Returns the result of recomputing the view's body with the provided
/// animation. /// animation.
/// - Parameters: /// - Parameters:

View File

@ -31,9 +31,14 @@ struct AlertInfo<T: Hashable>: Identifiable {
/// The alert's message (optional). /// The alert's message (optional).
var message: String? var message: String?
/// The alert's primary button title and action. Defaults to an Ok button with no action. /// The alert's primary button title and action. Defaults to an Ok button with no action.
var primaryButton: (title: String, action: (() -> Void)?) = (ElementL10n.ok, nil) var primaryButton = AlertButton(title: ElementL10n.ok, action: nil)
/// The alert's secondary button title and action. /// The alert's secondary button title and action.
var secondaryButton: (title: String, action: (() -> Void)?)? var secondaryButton: AlertButton?
}
struct AlertButton {
let title: String
let action: (() -> Void)?
} }
extension AlertInfo { extension AlertInfo {
@ -79,7 +84,7 @@ extension AlertInfo {
} }
} }
private func alertButton(for buttonParameters: (title: String, action: (() -> Void)?)) -> Alert.Button { private func alertButton(for buttonParameters: AlertButton) -> Alert.Button {
guard let action = buttonParameters.action else { guard let action = buttonParameters.action else {
return .default(Text(buttonParameters.title)) return .default(Text(buttonParameters.title))
} }

View File

@ -1,146 +0,0 @@
/*
Copyright 2019 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 Foundation
import UIKit
/// Used to present activity indicator on a view
final class ActivityIndicatorPresenter: ActivityIndicatorPresenterType {
// MARK: - Constants
private enum Constants {
static let animationDuration: TimeInterval = 0.3
static let backgroundOverlayColor = UIColor.clear
static let backgroundOverlayAlpha: CGFloat = 1.0
}
// MARK: - Properties
private weak var backgroundOverlayView: UIView?
private weak var activityIndicatorView: ActivityIndicatorView?
private weak var presentingView: UIView?
var isPresenting: Bool {
activityIndicatorView != nil
}
// MARK: - Public
func presentActivityIndicator(on view: UIView, animated: Bool, completion: (() -> Void)? = nil) {
if presentingView != nil {
if let completion {
completion()
}
return
}
presentingView = view
view.isUserInteractionEnabled = false
let backgroundOverlayView = createBackgroundOverlayView(with: view.frame)
let activityIndicatorView = ActivityIndicatorView()
// Add activityIndicatorView on backgroundOverlayView centered
backgroundOverlayView.addSubview(activityIndicatorView)
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
activityIndicatorView.centerXAnchor.constraint(equalTo: backgroundOverlayView.centerXAnchor).isActive = true
activityIndicatorView.centerYAnchor.constraint(equalTo: backgroundOverlayView.centerYAnchor).isActive = true
activityIndicatorView.startAnimating()
backgroundOverlayView.alpha = 0
backgroundOverlayView.isHidden = false
view.vc_addSubViewMatchingParent(backgroundOverlayView)
self.backgroundOverlayView = backgroundOverlayView
self.activityIndicatorView = activityIndicatorView
let animationInstructions = {
backgroundOverlayView.alpha = Constants.backgroundOverlayAlpha
}
if animated {
UIView.animate(withDuration: Constants.animationDuration) {
animationInstructions()
} completion: { _ in
completion?()
}
} else {
animationInstructions()
completion?()
}
}
func removeCurrentActivityIndicator(animated: Bool, completion: (() -> Void)? = nil) {
guard let presentingView,
let backgroundOverlayView = backgroundOverlayView,
let activityIndicatorView = activityIndicatorView else {
return
}
presentingView.isUserInteractionEnabled = true
self.presentingView = nil
let animationInstructions = {
activityIndicatorView.alpha = 0
}
let animationCompletionInstructions = {
activityIndicatorView.stopAnimating()
backgroundOverlayView.isHidden = true
backgroundOverlayView.removeFromSuperview()
}
if animated {
UIView.animate(withDuration: Constants.animationDuration) {
animationInstructions()
} completion: { _ in
animationCompletionInstructions()
}
} else {
animationInstructions()
animationCompletionInstructions()
}
}
// MARK: - Private
private func createBackgroundOverlayView(with frame: CGRect = CGRect.zero) -> UIView {
let backgroundOverlayView = UIView(frame: frame)
backgroundOverlayView.backgroundColor = Constants.backgroundOverlayColor
backgroundOverlayView.alpha = Constants.backgroundOverlayAlpha
return backgroundOverlayView
}
}
private extension UIView {
/// Add a subview matching parent view using autolayout
@objc func vc_addSubViewMatchingParent(_ subView: UIView) {
addSubview(subView)
subView.translatesAutoresizingMaskIntoConstraints = false
let views = ["view": subView]
["H:|[view]|", "V:|[view]|"].forEach { vfl in
let constraints = NSLayoutConstraint.constraints(withVisualFormat: vfl,
options: [],
metrics: nil,
views: views)
constraints.forEach { $0.isActive = true }
}
}
}

View File

@ -1,34 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol used to present activity indicator on a view
protocol ActivityIndicatorPresenterType {
func presentActivityIndicator(on view: UIView, animated: Bool, completion: (() -> Void)?)
func removeCurrentActivityIndicator(animated: Bool, completion: (() -> Void)?)
}
// `ActivityIndicatorPresenterType` default implementation
extension ActivityIndicatorPresenterType {
func presentActivityIndicator(on view: UIView, animated: Bool) {
presentActivityIndicator(on: view, animated: animated, completion: nil)
}
func removeCurrentActivityIndicator(animated: Bool) {
removeCurrentActivityIndicator(animated: animated, completion: nil)
}
}

View File

@ -1,111 +0,0 @@
/*
Copyright 2019 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 UIKit
final class ActivityIndicatorView: UIView {
// MARK: - Constants
private enum Constants {
static let cornerRadius: CGFloat = 5.0
static let activityIndicatorMargin = CGSize(width: 30.0, height: 30.0)
}
// MARK: - Properties
// MARK: Outlets
@IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet private var activityIndicatorBackgroundView: UIView!
// MARK: Public
var color: UIColor? {
get {
activityIndicatorView.color
}
set {
activityIndicatorView.color = newValue
}
}
// MARK: - Setup
private func commonInit() {
activityIndicatorBackgroundView.layer.masksToBounds = true
}
convenience init() {
self.init(frame: CGRect.zero)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
loadNibContent()
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
loadNibContent()
commonInit()
}
// MARK: - Overrides
override var intrinsicContentSize: CGSize {
CGSize(width: activityIndicatorView.intrinsicContentSize.width + Constants.activityIndicatorMargin.width,
height: activityIndicatorView.intrinsicContentSize.height + Constants.activityIndicatorMargin.height)
}
override func layoutSubviews() {
super.layoutSubviews()
activityIndicatorBackgroundView.layer.cornerRadius = Constants.cornerRadius
}
// MARK: - Public
func startAnimating() {
activityIndicatorView.startAnimating()
}
func stopAnimating() {
activityIndicatorView.stopAnimating()
}
}
private extension UIView {
static var nib: UINib {
UINib(nibName: String(describing: self), bundle: Bundle(for: self))
}
func loadNibContent() {
let layoutAttributes: [NSLayoutConstraint.Attribute] = [.top, .leading, .bottom, .trailing]
for case let view as UIView in type(of: self).nib.instantiate(withOwner: self, options: nil) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
NSLayoutConstraint.activate(layoutAttributes.map { attribute in
NSLayoutConstraint(
item: view, attribute: attribute,
relatedBy: .equal,
toItem: self, attribute: attribute,
multiplier: 1, constant: 0.0
)
})
}
}
}

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.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="ActivityIndicatorView" customModule="Riot" customModuleProvider="target">
<connections>
<outlet property="activityIndicatorBackgroundView" destination="2qY-Ui-WjQ" id="obw-ml-Ppb"/>
<outlet property="activityIndicatorView" destination="32q-cl-Ru4" id="38r-gn-BJg"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="DuQ-Nt-i8B">
<rect key="frame" x="0.0" y="0.0" width="375" height="204"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2qY-Ui-WjQ">
<rect key="frame" x="0.0" y="0.0" width="375" height="204"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" style="white" translatesAutoresizingMaskIntoConstraints="NO" id="32q-cl-Ru4">
<rect key="frame" x="15" y="15" width="345" height="174"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" red="0.80000000000000004" green="0.80000000000000004" blue="0.80000000000000004" alpha="0.89787029109589045" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstItem="32q-cl-Ru4" firstAttribute="top" secondItem="2qY-Ui-WjQ" secondAttribute="top" constant="15" id="hLL-Ao-h6a"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="centerX" secondItem="2qY-Ui-WjQ" secondAttribute="centerX" id="n1z-Rb-ce3"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="leading" secondItem="2qY-Ui-WjQ" secondAttribute="leading" constant="15" id="tTB-ZQ-Xrm"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="centerY" secondItem="2qY-Ui-WjQ" secondAttribute="centerY" id="ty6-5W-zen"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Zud-2y-G9d" firstAttribute="bottom" secondItem="2qY-Ui-WjQ" secondAttribute="bottom" id="F2N-Wt-CCH"/>
<constraint firstItem="2qY-Ui-WjQ" firstAttribute="top" secondItem="Zud-2y-G9d" secondAttribute="top" id="V7A-HL-11f"/>
<constraint firstItem="2qY-Ui-WjQ" firstAttribute="leading" secondItem="Zud-2y-G9d" secondAttribute="leading" id="jbB-4Q-tNb"/>
<constraint firstItem="Zud-2y-G9d" firstAttribute="trailing" secondItem="2qY-Ui-WjQ" secondAttribute="trailing" id="psU-2p-PBH"/>
</constraints>
<nil key="simulatedStatusBarMetrics"/>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<viewLayoutGuide key="safeArea" id="Zud-2y-G9d"/>
<point key="canvasLocation" x="55" y="-605"/>
</view>
</objects>
</document>

View File

@ -1,75 +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 UIKit
/// A presenter responsible for showing / hiding a full-screen loading view that obscures (and thus disables) all other controls.
/// It is managed by a `UserIndicator`, meaning the `present` and `dismiss` methods will be called when the parent `UserIndicator` starts or completes.
class FullscreenLoadingViewPresenter: UserIndicatorViewPresentable {
private let label: String
private let presentationContext: UserIndicatorPresentationContext
private weak var view: UIView?
private var animator: UIViewPropertyAnimator?
init(label: String, presentationContext: UserIndicatorPresentationContext) {
self.label = label
self.presentationContext = presentationContext
}
func present() {
// Find the current top navigation controller
var presentingController: UIViewController? = presentationContext.indicatorPresentingViewController
while presentingController?.navigationController != nil {
presentingController = presentingController?.navigationController
}
guard let presentingController else {
return
}
let view = LabelledActivityIndicatorView(text: label)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
presentingController.view.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: presentingController.view.topAnchor),
view.bottomAnchor.constraint(equalTo: presentingController.view.bottomAnchor),
view.leadingAnchor.constraint(equalTo: presentingController.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: presentingController.view.trailingAnchor)
])
view.alpha = 0
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) {
view.alpha = 1
}
animator?.startAnimation()
}
func dismiss() {
guard let view, view.superview != nil else {
return
}
animator?.stopAnimation(true)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeIn) {
view.alpha = 0
}
animator?.addCompletion { _ in
view.removeFromSuperview()
}
animator?.startAnimation()
}
}

View File

@ -1,90 +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 Foundation
import UIKit
final class LabelledActivityIndicatorView: UIView {
private enum Constants {
static let padding = UIEdgeInsets(top: 20, left: 40, bottom: 15, right: 40)
static let activityIndicatorScale = CGFloat(1.5)
static let cornerRadius: CGFloat = 12.0
static let stackSpacing: CGFloat = 15
static let backgroundOpacity: CGFloat = 0.5
}
private let stackBackgroundView: UIView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
view.layer.cornerRadius = Constants.cornerRadius
view.clipsToBounds = true
return view
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fill
stack.alignment = .center
stack.spacing = Constants.stackSpacing
return stack
}()
private let activityIndicator: UIActivityIndicatorView = {
let view = UIActivityIndicatorView()
view.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale)
view.startAnimating()
return view
}()
private let label = UILabel()
init(text: String) {
super.init(frame: .zero)
setup(text: text)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(text: String) {
setupStackView()
label.text = text
label.textColor = .element.primaryContent
}
private func setupStackView() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor)
])
stackView.addArrangedSubview(activityIndicator)
stackView.addArrangedSubview(label)
insertSubview(stackBackgroundView, belowSubview: stackView)
stackBackgroundView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackBackgroundView.topAnchor.constraint(equalTo: stackView.topAnchor, constant: -Constants.padding.top),
stackBackgroundView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: Constants.padding.bottom),
stackBackgroundView.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: -Constants.padding.left),
stackBackgroundView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: Constants.padding.right)
])
}
}

View File

@ -1,87 +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 Foundation
import UIKit
class RectangleToastView: UIView {
private enum Constants {
static let padding = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
static let cornerRadius: CGFloat = 8.0
}
private lazy var imageView: UIImageView = {
let view = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
return view
}()
private lazy var messageLabel: UILabel = {
let label = UILabel()
label.backgroundColor = .clear
label.numberOfLines = 0
label.textAlignment = .left
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var stackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
result.distribution = .fill
result.alignment = .center
result.spacing = 8.0
result.backgroundColor = .clear
addSubview(result)
result.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
result.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding.left),
result.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding.top),
result.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding.right),
result.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding.bottom)
])
return result
}()
init(withMessage message: String?,
image: UIImage? = nil) {
super.init(frame: .zero)
if let image {
imageView.image = image
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: image.size.width),
imageView.heightAnchor.constraint(equalToConstant: image.size.height)
])
stackView.addArrangedSubview(imageView)
}
messageLabel.text = message
stackView.addArrangedSubview(messageLabel)
stackView.layoutIfNeeded()
layer.cornerRadius = Constants.cornerRadius
layer.masksToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,124 +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 UIKit
class RoundedToastView: UIView {
private struct ShadowStyle {
let offset: CGSize
let radius: CGFloat
let opacity: Float
}
private enum Constants {
static let padding = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
static let activityIndicatorScale = CGFloat(0.75)
static let imageViewSize = CGFloat(15)
static let lightShadow = ShadowStyle(offset: .init(width: 0, height: 4), radius: 12, opacity: 0.1)
static let darkShadow = ShadowStyle(offset: .init(width: 0, height: 4), radius: 4, opacity: 0.2)
}
private lazy var activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale)
indicator.startAnimating()
return indicator
}()
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: Constants.imageViewSize),
imageView.heightAnchor.constraint(equalToConstant: Constants.imageViewSize)
])
return imageView
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 5
return stack
}()
private let label = UILabel()
init(viewState: ToastViewState) {
super.init(frame: .zero)
setup(viewState: viewState)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(viewState: ToastViewState) {
backgroundColor = .clear
clipsToBounds = true
setupBackgroundMaterial()
setupStackView()
stackView.addArrangedSubview(toastView(for: viewState.style))
stackView.addArrangedSubview(label)
label.text = viewState.label
label.textColor = .element.primaryContent
}
private func setupBackgroundMaterial() {
let material = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
addSubview(material)
material.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
material.topAnchor.constraint(equalTo: topAnchor),
material.bottomAnchor.constraint(equalTo: bottomAnchor),
material.leadingAnchor.constraint(equalTo: leadingAnchor),
material.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
private func setupStackView() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding.top),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding.bottom),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding.left),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding.right)
])
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = layer.frame.height / 2
}
private func toastView(for style: ToastViewState.Style) -> UIView {
switch style {
case .loading:
return activityIndicator
case .success:
imageView.image = UIImage(systemName: "checkmark")
return imageView
case .error:
imageView.image = UIImage(systemName: "xmark")
return imageView
}
}
}

View File

@ -1,71 +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 UIKit
/// A presenter responsible for showing / hiding a toast view for loading spinners or success messages.
/// It is managed by an `UserIndicator`, meaning the `present` and `dismiss` methods will be called when the parent `UserIndicator` starts or completes.
class ToastViewPresenter: UserIndicatorViewPresentable {
private let viewState: ToastViewState
private let presentationContext: UserIndicatorPresentationContext
private weak var view: UIView?
private var animator: UIViewPropertyAnimator?
init(viewState: ToastViewState, presentationContext: UserIndicatorPresentationContext) {
self.viewState = viewState
self.presentationContext = presentationContext
}
func present() {
guard let viewController = presentationContext.indicatorPresentingViewController else {
return
}
let view = RoundedToastView(viewState: viewState)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
viewController.view.addSubview(view)
NSLayoutConstraint.activate([
view.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor),
view.topAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor)
])
view.alpha = 0
view.transform = .init(translationX: 0, y: 5)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) {
view.alpha = 1
view.transform = .identity
}
animator?.startAnimation()
}
func dismiss() {
guard let view, view.superview != nil else {
return
}
animator?.stopAnimation(true)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeIn) {
view.alpha = 0
view.transform = .init(translationX: 0, y: -5)
}
animator?.addCompletion { _ in
view.removeFromSuperview()
}
animator?.startAnimation()
}
}

View File

@ -1,103 +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 Foundation
import UIKit
/// A `UserIndicator` represents the state of a temporary visual indicator, such as loading spinner, success notification or an error message. It does not directly manage the UI, instead it delegates to a `presenter`
/// whenever the UI should be shown or hidden.
///
/// More than one `UserIndicator` may be requested by the system at the same time (e.g. global syncing vs local refresh),
/// and the `UserIndicatorQueue` will ensure that only one indicator is shown at a given time, putting the other in a pending queue.
///
/// A client that requests an indicator can specify a default timeout after which the indicator is dismissed, or it has to be manually
/// responsible for dismissing it via `cancel` method, or by deallocating itself.
public class UserIndicator {
public enum State {
case pending
case executing
case completed
}
private let request: UserIndicatorRequest
private let completion: () -> Void
public private(set) var state: State
public init(request: UserIndicatorRequest, completion: @escaping () -> Void) {
self.request = request
self.completion = completion
state = .pending
}
deinit {
complete()
}
internal func start() {
guard state == .pending else {
return
}
state = .executing
request.presenter.present()
switch request.dismissal {
case .manual:
break
case .timeout(let interval):
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
self?.complete()
}
}
}
/// Cancel the indicator, triggering any dismissal action / animation
///
/// Note: clients can call this method directly, if they have access to the `UserIndicator`. Alternatively
/// deallocating the `UserIndicator` will call `cancel` automatically.
/// Once cancelled, `UserIndicatorQueue` will automatically start the next `UserIndicator` in the queue.
public func cancel() {
complete()
}
private func complete() {
guard state != .completed else {
return
}
if state == .executing {
request.presenter.dismiss()
}
state = .completed
completion()
}
}
public extension UserIndicator {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == UserIndicator {
collection.append(self)
}
}
public extension Collection where Element == UserIndicator {
func cancelAll() {
forEach {
$0.cancel()
}
}
}

View File

@ -1,25 +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 Foundation
/// Different ways in which a `UserIndicator` can be dismissed
public enum UserIndicatorDismissal {
/// The `UserIndicator` will not manage the dismissal, but will expect the calling client to do so manually
case manual
/// The `UserIndicator` will be automatically dismissed after `TimeInterval`
case timeout(TimeInterval)
}

View File

@ -1,42 +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 Foundation
import UIKit
/// The presentation context is used by `UserIndicatorViewPresentable`s to display content
/// on the screen and it serves two primary purposes:
///
/// - abstraction on top of UIKit (passing context instead of view controllers)
/// - immutable context passed at init with variable presenting view controller
/// (e.g. depending on collapsed / uncollapsed iPad presentation that changes
/// at runtime)
public protocol UserIndicatorPresentationContext {
var indicatorPresentingViewController: UIViewController? { get }
}
/// A simple implementation of `UserIndicatorPresentationContext` that uses a weak reference
/// to the passed-in view controller as the presentation context.
public class StaticUserIndicatorPresentationContext: UserIndicatorPresentationContext {
// The presenting view controller will be the parent of the user indicator,
// and the indicator holds a strong reference to the context, so the view controller
// must be decleared `weak` to avoid a retain cycle
public private(set) weak var indicatorPresentingViewController: UIViewController?
public init(viewController: UIViewController) {
indicatorPresentingViewController = viewController
}
}

View File

@ -1,133 +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 UIKit
/// A set of user interactors commonly used across the app
enum UserIndicatorType {
case loading(label: String, isInteractionBlocking: Bool)
case success(label: String)
case error(label: String)
}
/// A presenter which can handle `UserIndicatorType` by creating the underlying `UserIndicator`
/// and adding it to its `UserIndicatorQueue`
protocol UserIndicatorTypePresenterProtocol {
/// Present a new type of user indicator, such as loading spinner or success message.
///
/// The presenter will internally convert the type into a `UserIndicator` and add it to its internal queue
/// of other indicators.
///
/// If the queue is empty, the indicator will be displayed immediately, otherwise it will be pending
/// until the previously added indicators have completed / been cancelled.
///
/// To remove an indicator, `cancel` or deallocate the returned `UserIndicator`
func present(_ type: UserIndicatorType) -> UserIndicator
/// The queue of user indicators owned by the presenter
///
/// Clients can access the queue to add custom `UserIndicatorRequest`s
/// above and beyond those defined by `UserIndicatorType`
var queue: UserIndicatorQueue { get }
}
class UserIndicatorTypePresenter: UserIndicatorTypePresenterProtocol {
private let presentationContext: UserIndicatorPresentationContext
let queue: UserIndicatorQueue
init(presentationContext: UserIndicatorPresentationContext) {
self.presentationContext = presentationContext
queue = UserIndicatorQueue()
}
convenience init(presentingViewController: UIViewController) {
let context = StaticUserIndicatorPresentationContext(viewController: presentingViewController)
self.init(presentationContext: context)
}
func present(_ type: UserIndicatorType) -> UserIndicator {
let request = userIndicatorRequest(for: type)
return queue.add(request)
}
private func userIndicatorRequest(for type: UserIndicatorType) -> UserIndicatorRequest {
switch type {
case .loading(let label, let isInteractionBlocking):
if isInteractionBlocking {
return fullScreenLoadingRequest(label: label)
} else {
return loadingRequest(label: label)
}
case .success(let label):
return successRequest(label: label)
case .error(let label):
return errorRequest(label: label)
}
}
private func loadingRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .loading,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .manual
)
}
private func fullScreenLoadingRequest(label: String) -> UserIndicatorRequest {
let presenter = FullscreenLoadingViewPresenter(
label: label,
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .manual
)
}
private func successRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .success,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .timeout(1.5)
)
}
private func errorRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .error,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .timeout(1.5)
)
}
}

View File

@ -1,63 +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 Foundation
/// A FIFO queue which will ensure only one user indicator is shown at a given time.
///
/// `UserIndicatorQueue` offers a `shared` queue that can be used by any clients app-wide, but clients are also allowed
/// to create local `UserIndicatorQueue` if the context requres multiple simultaneous indicators.
public class UserIndicatorQueue {
private class Weak<T: AnyObject> {
weak var element: T?
init(_ element: T) {
self.element = element
}
}
private var queue: [Weak<UserIndicator>]
public init() {
queue = []
}
/// Add a new indicator to the queue by providing a request.
///
/// The queue will start the indicator right away, if there are no currently running indicators,
/// otherwise the indicator will be put on hold.
public func add(_ request: UserIndicatorRequest) -> UserIndicator {
let indicator = UserIndicator(request: request) { [weak self] in
self?.startNextIfIdle()
}
queue.append(Weak(indicator))
startNextIfIdle()
return indicator
}
private func startNextIfIdle() {
cleanup()
if let indicator = queue.first?.element, indicator.state == .pending {
indicator.start()
}
}
private func cleanup() {
queue.removeAll {
$0.element == nil || $0.element?.state == .completed
}
}
}

View File

@ -1,63 +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 Foundation
typealias UserIndicatorCancel = () -> Void
/// An abstraction on top of `UserIndicatorTypePresenterProtocol` which manages and stores the individual user indicators.
/// When used to present an indicator the `UserIndicatorStore` will instead returns a simple callback function to the clients
/// letting them cancel the indicators without worrying about memory.
@objc final class UserIndicatorStore: NSObject {
private let presenter: UserIndicatorTypePresenterProtocol
private var indicators: [UserIndicator]
init(presenter: UserIndicatorTypePresenterProtocol) {
self.presenter = presenter
indicators = []
}
/// Present a new type of user indicator, such as loading spinner or success message.
/// To remove an indicator, call the returned `UserIndicatorCancel` function
func present(type: UserIndicatorType) -> UserIndicatorCancel {
let indicator = presenter.present(type)
indicators.append(indicator)
return {
indicator.cancel()
}
}
/// Present a loading indicator.
/// To remove the indicator call the returned `UserIndicatorCancel` function
///
/// Note: This is a convenience function callable by objective-c code
@objc func presentLoading(label: String, isInteractionBlocking: Bool) -> UserIndicatorCancel {
present(
type: .loading(
label: label,
isInteractionBlocking: isInteractionBlocking
)
)
}
/// Present a success message that will be automatically dismissed after a few seconds.
///
/// Note: This is a convenience function callable by objective-c code
@objc func presentSuccess(label: String) {
let indicator = presenter.present(.success(label: label))
indicators.append(indicator)
}
}

View File

@ -1,25 +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 Foundation
/// A presenter associated with and called by a `UserIndicator`, and responsible for the underlying view shown on the screen.
public protocol UserIndicatorViewPresentable {
/// Called when the `UserIndicator` is started (manually or by the `UserIndicatorQueue`)
func present()
/// Called when the `UserIndicator` is manually cancelled or completed
func dismiss()
}

View File

@ -14,17 +14,12 @@
// limitations under the License. // limitations under the License.
// //
@testable import ElementX
import Foundation import Foundation
class UserIndicatorPresenterSpy: UserIndicatorViewPresentable { struct MockUserNotificationController: UserNotificationControllerProtocol {
var intel = [String]() func submitNotification(_ notification: UserNotification) { }
func present() { func retractNotificationWithId(_ id: String) { }
intel.append(#function)
}
func dismiss() { func retractAllNotifications() { }
intel.append(#function)
}
} }

View File

@ -0,0 +1,30 @@
//
// 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
enum UserNotificationType {
case toast
case modal
}
struct UserNotification: Equatable, Identifiable {
var id: String = UUID().uuidString
var type = UserNotificationType.toast
var title: String
var iconName: String?
var persistent = false
}

View File

@ -0,0 +1,80 @@
//
// 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
class UserNotificationController: ObservableObject, UserNotificationControllerProtocol {
private let rootCoordinator: CoordinatorProtocol
private var dismisalTimer: Timer?
private var displayTimes = [String: Date]()
var nonPersistentDisplayDuration = 2.5
var minimumDisplayDuration = 0.5
@Published private(set) var activeNotification: UserNotification?
private(set) var notificationQueue = [UserNotification]() {
didSet {
activeNotification = notificationQueue.last
if let activeNotification, !activeNotification.persistent {
dismisalTimer?.invalidate()
dismisalTimer = Timer.scheduledTimer(withTimeInterval: nonPersistentDisplayDuration, repeats: false) { [weak self] _ in
self?.retractNotificationWithId(activeNotification.id)
}
}
}
}
init(rootCoordinator: CoordinatorProtocol) {
self.rootCoordinator = rootCoordinator
}
func toPresentable() -> AnyView {
AnyView(
UserNotificationPresenter(userNotificationController: self, rootView: rootCoordinator.toPresentable())
)
}
func submitNotification(_ notification: UserNotification) {
if let index = notificationQueue.firstIndex(where: { $0.id == notification.id }) {
notificationQueue[index] = notification
} else {
retractNotificationWithId(notification.id)
notificationQueue.append(notification)
}
displayTimes[notification.id] = .now
}
func retractAllNotifications() {
for notification in notificationQueue {
retractNotificationWithId(notification.id)
}
}
func retractNotificationWithId(_ id: String) {
guard let displayTime = displayTimes[id], abs(displayTime.timeIntervalSinceNow) <= minimumDisplayDuration else {
notificationQueue.removeAll { $0.id == id }
return
}
Timer.scheduledTimer(withTimeInterval: minimumDisplayDuration, repeats: false) { [weak self] _ in
self?.notificationQueue.removeAll { $0.id == id }
self?.displayTimes[id] = nil
}
}
}

View File

@ -14,11 +14,10 @@
// limitations under the License. // limitations under the License.
// //
import UIKit import Foundation
class ElementNavigationController: UINavigationController { protocol UserNotificationControllerProtocol: CoordinatorProtocol {
override func viewWillLayoutSubviews() { func submitNotification(_ notification: UserNotification)
super.viewWillLayoutSubviews() func retractNotificationWithId(_ id: String)
navigationBar.topItem?.backButtonDisplayMode = .minimal func retractAllNotifications()
}
} }

View File

@ -0,0 +1,70 @@
//
// 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 UserNotificationModalView: View {
let notification: UserNotification
var body: some View {
ZStack {
VStack(spacing: 12.0) {
ProgressView()
HStack {
if let iconName = notification.iconName {
Image(systemName: iconName)
}
Text(notification.title)
.font(.element.body)
.foregroundColor(.element.primaryContent)
}
}
.padding()
.frame(minWidth: 150.0)
.background(Color.element.quinaryContent)
.clipShape(RoundedCornerShape(radius: 12.0, corners: .allCorners))
.shadow(color: .black.opacity(0.1), radius: 10.0, y: 4.0)
.transition(.opacity)
}
.id(notification.id)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.1))
.ignoresSafeArea()
}
private var toastTransition: AnyTransition {
AnyTransition
.asymmetric(insertion: .move(edge: .top),
removal: .move(edge: .bottom))
.combined(with: .opacity)
}
}
struct UserNotificationModalView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
private static var body: some View {
VStack {
UserNotificationModalView(notification: UserNotification(type: .modal,
title: "Successfully logged in",
iconName: "checkmark"))
}
}
}

View File

@ -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
struct UserNotificationPresenter: View {
@ObservedObject var userNotificationController: UserNotificationController
let rootView: AnyView
var body: some View {
ZStack(alignment: .top) {
rootView
notificationViewFor(notification: userNotificationController.activeNotification)
}
.animation(.elementDefault, value: userNotificationController.activeNotification)
}
@ViewBuilder
private func notificationViewFor(notification: UserNotification?) -> some View {
ZStack { // Need a container to properly animate transitions
if let notification {
switch notification.type {
case .toast:
UserNotificationToastView(notification: notification)
case .modal:
UserNotificationModalView(notification: notification)
}
}
}
}
}

View File

@ -0,0 +1,63 @@
//
// 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 UserNotificationToastView: View {
let notification: UserNotification
var body: some View {
HStack {
if let iconName = notification.iconName {
Image(systemName: iconName)
}
Text(notification.title)
.font(.element.footnote)
.foregroundColor(.element.primaryContent)
}
.id(notification.id)
.padding(.horizontal, 12.0)
.padding(.vertical, 10.0)
.frame(minWidth: 150.0)
.background(Color.element.quaternaryContent)
.clipShape(RoundedCornerShape(radius: 24.0, corners: .allCorners))
.shadow(color: .black.opacity(0.1), radius: 10.0, y: 4.0)
.transition(toastTransition)
}
private var toastTransition: AnyTransition {
AnyTransition
.asymmetric(insertion: .move(edge: .top),
removal: .move(edge: .bottom))
.combined(with: .opacity)
}
}
struct UserNotificationToastView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
private static var body: some View {
VStack {
UserNotificationToastView(notification: UserNotification(title: "Successfully logged in",
iconName: "checkmark"))
UserNotificationToastView(notification: UserNotification(title: "Toast without icon"))
}
}
}

View File

@ -21,39 +21,22 @@ struct AnalyticsPromptCoordinatorParameters {
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
} }
final class AnalyticsPromptCoordinator: Coordinator, Presentable { final class AnalyticsPromptCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: AnalyticsPromptCoordinatorParameters private let parameters: AnalyticsPromptCoordinatorParameters
private let analyticsPromptHostingController: UIViewController private var viewModel: AnalyticsPromptViewModel
private var analyticsPromptViewModel: AnalyticsPromptViewModel
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor () -> Void)? var callback: (@MainActor () -> Void)?
// MARK: - Setup
init(parameters: AnalyticsPromptCoordinatorParameters) { init(parameters: AnalyticsPromptCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL) viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
let view = AnalyticsPrompt(context: viewModel.context)
analyticsPromptViewModel = viewModel
analyticsPromptHostingController = UIHostingController(rootView: view)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] result in
analyticsPromptViewModel.callback = { [weak self] result in
MXLog.debug("AnalyticsPromptViewModel did complete with result: \(result).") MXLog.debug("AnalyticsPromptViewModel did complete with result: \(result).")
guard let self else { return } guard let self else { return }
@ -69,7 +52,7 @@ final class AnalyticsPromptCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { analyticsPromptHostingController } func toPresentable() -> AnyView {
AnyView(AnalyticsPrompt(context: viewModel.context))
func stop() { } }
} }

View File

@ -14,7 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import UIKit import SwiftUI
@MainActor @MainActor
protocol AuthenticationCoordinatorDelegate: AnyObject { protocol AuthenticationCoordinatorDelegate: AnyObject {
@ -22,30 +22,20 @@ protocol AuthenticationCoordinatorDelegate: AnyObject {
didLoginWithSession userSession: UserSessionProtocol) didLoginWithSession userSession: UserSessionProtocol)
} }
class AuthenticationCoordinator: Coordinator, Presentable { class AuthenticationCoordinator: CoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol private let authenticationService: AuthenticationServiceProxyProtocol
private let navigationRouter: NavigationRouter private let navigationController: NavigationController
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
var childCoordinators: [Coordinator] = []
weak var delegate: AuthenticationCoordinatorDelegate? weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol, init(authenticationService: AuthenticationServiceProxyProtocol,
navigationRouter: NavigationRouter) { navigationController: NavigationController) {
self.authenticationService = authenticationService self.authenticationService = authenticationService
self.navigationRouter = navigationRouter self.navigationController = navigationController
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: navigationRouter.toPresentable())
} }
func start() { func start() {
showSplashScreen() showOnboarding()
}
func toPresentable() -> UIViewController {
navigationRouter.toPresentable()
} }
func stop() { func stop() {
@ -54,9 +44,8 @@ class AuthenticationCoordinator: Coordinator, Presentable {
// MARK: - Private // MARK: - Private
/// Shows the splash screen as the root view in the navigation stack. private func showOnboarding() {
private func showSplashScreen() { let coordinator = OnboardingCoordinator()
let coordinator = SplashScreenCoordinator()
coordinator.callback = { [weak self] action in coordinator.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
@ -66,12 +55,7 @@ class AuthenticationCoordinator: Coordinator, Presentable {
} }
} }
coordinator.start() navigationController.setRootCoordinator(coordinator)
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
private func startAuthentication() async { private func startAuthentication() async {
@ -89,7 +73,8 @@ class AuthenticationCoordinator: Coordinator, Presentable {
private func showServerSelectionScreen() { private func showServerSelectionScreen() {
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService, let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: false) userNotificationController: ServiceLocator.shared.userNotificationController,
isModallyPresented: false)
let coordinator = ServerSelectionCoordinator(parameters: parameters) let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in coordinator.callback = { [weak self] action in
@ -103,17 +88,12 @@ class AuthenticationCoordinator: Coordinator, Presentable {
} }
} }
coordinator.start() navigationController.push(coordinator)
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
private func showLoginScreen() { private func showLoginScreen() {
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService, let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
navigationRouter: navigationRouter) navigationController: navigationController)
let coordinator = LoginCoordinator(parameters: parameters) let coordinator = LoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in coordinator.callback = { [weak self] action in
@ -125,12 +105,7 @@ class AuthenticationCoordinator: Coordinator, Presentable {
} }
} }
coordinator.start() navigationController.push(coordinator)
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) { private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
@ -142,21 +117,19 @@ class AuthenticationCoordinator: Coordinator, Presentable {
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
} }
coordinator.start() navigationController.setRootCoordinator(coordinator)
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
/// Show a blocking activity indicator. static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
private func startLoading() { 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() { private func stopLoading() {
activityIndicator = nil ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
} }
} }

View File

@ -21,7 +21,7 @@ struct LoginCoordinatorParameters {
/// The service used to authenticate the user. /// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol let authenticationService: AuthenticationServiceProxyProtocol
/// The navigation router used to present the server selection screen. /// The navigation router used to present the server selection screen.
let navigationRouter: NavigationRouterType let navigationController: NavigationController
} }
enum LoginCoordinatorAction { enum LoginCoordinatorAction {
@ -29,14 +29,10 @@ enum LoginCoordinatorAction {
case signedIn(UserSessionProtocol) case signedIn(UserSessionProtocol)
} }
final class LoginCoordinator: Coordinator, Presentable { final class LoginCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: LoginCoordinatorParameters private let parameters: LoginCoordinatorParameters
private let loginHostingController: UIViewController private var viewModel: LoginViewModelProtocol
private var loginViewModel: LoginViewModelProtocol private let hostingController: UIViewController
/// Passed to the OIDC service to provide a view controller from which to present the authentication session. /// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS? private let oidcUserAgent: OIDExternalUserAgentIOS?
@ -47,14 +43,8 @@ final class LoginCoordinator: Coordinator, Presentable {
} }
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService } private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var navigationRouter: NavigationRouterType { parameters.navigationRouter } private var navigationController: NavigationController { parameters.navigationController }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (LoginCoordinatorAction) -> Void)? var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
// MARK: - Setup // MARK: - Setup
@ -62,22 +52,16 @@ final class LoginCoordinator: Coordinator, Presentable {
init(parameters: LoginCoordinatorParameters) { init(parameters: LoginCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver) viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
loginViewModel = viewModel
let view = LoginScreen(context: viewModel.context) hostingController = UIHostingController(rootView: LoginScreen(context: viewModel.context))
loginHostingController = UIHostingController(rootView: view) oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: loginHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] action in
loginViewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("LoginViewModel did callback with result: \(action).") MXLog.debug("LoginViewModel did callback with result: \(action).")
@ -96,50 +80,51 @@ final class LoginCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController {
loginHostingController
}
func stop() { func stop() {
stopLoading() stopLoading()
} }
func toPresentable() -> AnyView {
AnyView(LoginScreen(context: viewModel.context))
}
// MARK: - Private // MARK: - Private
/// Show a blocking activity indicator whilst saving. static let loadingIndicatorIdentifier = "LoginCoordinatorLoading"
private func startLoading(isInteractionBlocking: Bool) { 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 { 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() { private func stopLoading() {
loginViewModel.update(isLoading: false) viewModel.update(isLoading: false)
activityIndicator = nil 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. /// Processes an error to either update the flow or display it to the user.
private func handleError(_ error: AuthenticationServiceError) { private func handleError(_ error: AuthenticationServiceError) {
switch error { switch error {
case .invalidCredentials: case .invalidCredentials:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam)) viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated: case .accountDeactivated:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount)) viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default: 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. /// Updates the view model with a different homeserver.
private func updateViewModel() { private func updateViewModel() {
loginViewModel.update(homeserver: authenticationService.homeserver) viewModel.update(homeserver: authenticationService.homeserver)
indicateSuccess() indicateSuccess()
} }
/// Presents the server selection screen as a modal. /// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() { private func presentServerSelectionScreen() {
MXLog.debug("PresentServerSelectionScreen") let serverSelectionNavigationController = NavigationController()
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationController)
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService, let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: true) userNotificationController: userNotificationController,
isModallyPresented: true)
let coordinator = ServerSelectionCoordinator(parameters: parameters) let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] action in coordinator.callback = { [weak self, weak coordinator] action in
guard let self, let coordinator = coordinator else { return } guard let self, let coordinator = coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: action) self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
} }
coordinator.start() serverSelectionNavigationController.setRootCoordinator(coordinator)
add(childCoordinator: coordinator)
let modalRouter = NavigationRouter(navigationController: ElementNavigationController()) navigationController.presentSheet(userNotificationController)
modalRouter.setRootModule(coordinator)
navigationRouter.present(modalRouter, animated: true)
} }
/// Handles the result from the server selection modal, dismissing it after updating the view. /// Handles the result from the server selection modal, dismissing it after updating the view.
private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator, private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator,
didCompleteWith action: ServerSelectionCoordinatorAction) { didCompleteWith action: ServerSelectionCoordinatorAction) {
navigationRouter.dismissModule(animated: true) { [weak self] in
if action == .updated { if action == .updated {
self?.updateViewModel() updateViewModel()
} }
self?.remove(childCoordinator: coordinator) navigationController.dismissSheet()
}
} }
/// Shows the forgot password screen. /// Shows the forgot password screen.
private func showForgotPasswordScreen() { private func showForgotPasswordScreen() {
loginViewModel.displayError(.alert("Not implemented.")) viewModel.displayError(.alert("Not implemented."))
} }
} }

View File

@ -27,18 +27,18 @@ enum MockServerSelectionScreenState: CaseIterable {
switch self { switch self {
case .matrix: case .matrix:
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org", return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
hasModalPresentation: true) isModallyPresented: true)
case .emptyAddress: case .emptyAddress:
return ServerSelectionViewModel(homeserverAddress: "", return ServerSelectionViewModel(homeserverAddress: "",
hasModalPresentation: true) isModallyPresented: true)
case .invalidAddress: case .invalidAddress:
let viewModel = ServerSelectionViewModel(homeserverAddress: "thisisbad", let viewModel = ServerSelectionViewModel(homeserverAddress: "thisisbad",
hasModalPresentation: true) isModallyPresented: true)
viewModel.displayError(.footerMessage(ElementL10n.unknownError)) viewModel.displayError(.footerMessage(ElementL10n.unknownError))
return viewModel return viewModel
case .nonModal: case .nonModal:
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org", return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
hasModalPresentation: false) isModallyPresented: false)
} }
} }
} }

View File

@ -19,8 +19,9 @@ import SwiftUI
struct ServerSelectionCoordinatorParameters { struct ServerSelectionCoordinatorParameters {
/// The service used to authenticate the user. /// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol let authenticationService: AuthenticationServiceProxyProtocol
let userNotificationController: UserNotificationControllerProtocol
/// Whether the screen is presented modally or within a navigation stack. /// Whether the screen is presented modally or within a navigation stack.
let hasModalPresentation: Bool let isModallyPresented: Bool
} }
enum ServerSelectionCoordinatorAction { enum ServerSelectionCoordinatorAction {
@ -28,45 +29,25 @@ enum ServerSelectionCoordinatorAction {
case dismiss case dismiss
} }
final class ServerSelectionCoordinator: Coordinator, Presentable { final class ServerSelectionCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: ServerSelectionCoordinatorParameters private let parameters: ServerSelectionCoordinatorParameters
private let serverSelectionHostingController: UIViewController private let userNotificationController: UserNotificationControllerProtocol
private var serverSelectionViewModel: ServerSelectionViewModelProtocol private var viewModel: ServerSelectionViewModelProtocol
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService } 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)? var callback: (@MainActor (ServerSelectionCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: ServerSelectionCoordinatorParameters) { init(parameters: ServerSelectionCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address, isModallyPresented: parameters.isModallyPresented)
hasModalPresentation: parameters.hasModalPresentation) userNotificationController = parameters.userNotificationController
let view = ServerSelectionScreen(context: viewModel.context)
serverSelectionViewModel = viewModel
serverSelectionHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: serverSelectionHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] action in
serverSelectionViewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("ServerSelectionViewModel did callback with action: \(action).") MXLog.debug("ServerSelectionViewModel did callback with action: \(action).")
@ -79,27 +60,24 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController {
serverSelectionHostingController
}
func stop() { func stop() {
stopLoading() stopLoading()
} }
// MARK: - Private func toPresentable() -> AnyView {
AnyView(ServerSelectionScreen(context: viewModel.context))
/// Show an activity indicator whilst loading. }
/// - Parameters:
/// - label: The label to show on the indicator. // MARK: - Private
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) { private func startLoading(label: String = ElementL10n.loading) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) userNotificationController.submitNotification(UserNotification(type: .modal,
title: label,
persistent: true))
} }
/// Hide the currently displayed activity indicator.
private func stopLoading() { 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. /// 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) { private func handleError(_ error: AuthenticationServiceError) {
switch error { switch error {
case .invalidServer, .invalidHomeserverAddress: case .invalidServer, .invalidHomeserverAddress:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound)) viewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
default: default:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.unknownError)) viewModel.displayError(.footerMessage(ElementL10n.unknownError))
} }
} }
} }

View File

@ -33,7 +33,7 @@ struct ServerSelectionViewState: BindableState {
/// An error message to be shown in the text field footer. /// An error message to be shown in the text field footer.
var footerErrorMessage: String? var footerErrorMessage: String?
/// Whether the screen is presented modally or within a navigation stack. /// 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. /// The message to show in the text field footer.
var footerMessage: String { var footerMessage: String {
@ -42,7 +42,7 @@ struct ServerSelectionViewState: BindableState {
/// The title shown on the confirm button. /// The title shown on the confirm button.
var buttonTitle: String { var buttonTitle: String {
hasModalPresentation ? ElementL10n.actionConfirm : ElementL10n.actionNext isModallyPresented ? ElementL10n.actionConfirm : ElementL10n.actionNext
} }
/// The text field is showing an error. /// The text field is showing an error.

View File

@ -29,10 +29,10 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
// MARK: - Setup // MARK: - Setup
init(homeserverAddress: String, hasModalPresentation: Bool) { init(homeserverAddress: String, isModallyPresented: Bool) {
let bindings = ServerSelectionBindings(homeserverAddress: homeserverAddress) let bindings = ServerSelectionBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: ServerSelectionViewState(bindings: bindings, super.init(initialViewState: ServerSelectionViewState(bindings: bindings,
hasModalPresentation: hasModalPresentation)) isModallyPresented: isModallyPresented))
} }
// MARK: - Public // MARK: - Public

View File

@ -44,6 +44,7 @@ struct ServerSelectionScreen: View {
.background(Color.element.background, ignoresSafeAreaEdges: .all) .background(Color.element.background, ignoresSafeAreaEdges: .all)
.toolbar { toolbar } .toolbar { toolbar }
.alert(item: $context.alertInfo) { $0.alert } .alert(item: $context.alertInfo) { $0.alert }
.interactiveDismissDisabled()
} }
/// The title, message and icon at the top of the screen. /// The title, message and icon at the top of the screen.
@ -91,7 +92,7 @@ struct ServerSelectionScreen: View {
@ToolbarContentBuilder @ToolbarContentBuilder
var toolbar: some ToolbarContent { var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
if context.viewState.hasModalPresentation { if context.viewState.isModallyPresented {
Button { context.send(viewAction: .dismiss) } label: { Button { context.send(viewAction: .dismiss) } label: {
Text(ElementL10n.actionCancel) Text(ElementL10n.actionCancel)
} }

View File

@ -40,55 +40,35 @@ enum SoftLogoutCoordinatorResult: CustomStringConvertible {
} }
} }
final class SoftLogoutCoordinator: Coordinator, Presentable { final class SoftLogoutCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: SoftLogoutCoordinatorParameters private let parameters: SoftLogoutCoordinatorParameters
private let softLogoutHostingController: UIViewController private var viewModel: SoftLogoutViewModelProtocol
private var softLogoutViewModel: SoftLogoutViewModelProtocol private let hostingController: UIViewController
/// Passed to the OIDC service to provide a view controller from which to present the authentication session. /// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS? private let oidcUserAgent: OIDExternalUserAgentIOS?
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var successIndicator: UserIndicator?
/// The wizard used to handle the registration flow. /// The wizard used to handle the registration flow.
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService } private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)? var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: SoftLogoutCoordinatorParameters) { @MainActor init(parameters: SoftLogoutCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let homeserver = parameters.authenticationService.homeserver let homeserver = parameters.authenticationService.homeserver
let viewModel = SoftLogoutViewModel(credentials: parameters.credentials, viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
homeserver: homeserver, homeserver: homeserver,
keyBackupNeeded: parameters.keyBackupNeeded) keyBackupNeeded: parameters.keyBackupNeeded)
softLogoutViewModel = viewModel
let view = SoftLogoutScreen(context: viewModel.context) hostingController = UIHostingController(rootView: SoftLogoutScreen(context: viewModel.context))
softLogoutHostingController = UIHostingController(rootView: view) oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: softLogoutHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: softLogoutHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("[SoftLogoutCoordinator] did start.") viewModel.callback = { [weak self] result in
softLogoutViewModel.callback = { [weak self] result in
guard let self else { return } guard let self else { return }
MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).") MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).")
@ -105,31 +85,36 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController {
softLogoutHostingController
}
func stop() { func stop() {
stopLoading() stopLoading()
} }
func toPresentable() -> AnyView {
AnyView(SoftLogoutScreen(context: viewModel.context))
}
// MARK: - Private // MARK: - Private
static let loadingIndicatorIdentifier = "SoftLogoutLoading"
/// Show an activity indicator whilst loading. /// Show an activity indicator whilst loading.
@MainActor private func startLoading() { @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. /// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() { @MainActor private func stopLoading() {
loadingIndicator = nil ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
} }
/// Shows the forgot password screen. /// Shows the forgot password screen.
@MainActor private func showForgotPasswordScreen() { @MainActor private func showForgotPasswordScreen() {
MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen") MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen")
softLogoutViewModel.displayError(.alert("Not implemented.")) viewModel.displayError(.alert("Not implemented."))
} }
/// Login with the supplied username and password. /// Login with the supplied username and password.
@ -177,11 +162,11 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
private func handleError(_ error: AuthenticationServiceError) { private func handleError(_ error: AuthenticationServiceError) {
switch error { switch error {
case .invalidCredentials: case .invalidCredentials:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam)) viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated: case .accountDeactivated:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount)) viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default: default:
softLogoutViewModel.displayError(.alert(ElementL10n.unknownError)) viewModel.displayError(.alert(ElementL10n.unknownError))
} }
} }
} }

View File

@ -16,57 +16,46 @@
import SwiftUI import SwiftUI
struct BugReportCoordinatorParameters { enum BugReportCoordinatorResult {
let bugReportService: BugReportServiceProtocol case cancel
let screenshot: UIImage? case finish
} }
final class BugReportCoordinator: Coordinator, Presentable { struct BugReportCoordinatorParameters {
// MARK: - Properties let bugReportService: BugReportServiceProtocol
let userNotificationController: UserNotificationControllerProtocol
// MARK: Private let screenshot: UIImage?
let isModallyPresented: Bool
}
final class BugReportCoordinator: CoordinatorProtocol {
private let parameters: BugReportCoordinatorParameters private let parameters: BugReportCoordinatorParameters
private let bugReportHostingController: UIViewController private var viewModel: BugReportViewModelProtocol
private var bugReportViewModel: BugReportViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol var completion: ((BugReportCoordinatorResult) -> Void)?
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
init(parameters: BugReportCoordinatorParameters) { init(parameters: BugReportCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = BugReportViewModel(bugReportService: parameters.bugReportService, viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
screenshot: parameters.screenshot) screenshot: parameters.screenshot,
let view = BugReportScreen(context: viewModel.context) isModallyPresented: parameters.isModallyPresented)
bugReportViewModel = viewModel
bugReportHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: bugReportHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] result in
bugReportViewModel.callback = { [weak self] result in
guard let self else { return } guard let self else { return }
MXLog.debug("BugReportViewModel did complete with result: \(result).") MXLog.debug("BugReportViewModel did complete with result: \(result).")
switch result { switch result {
case .cancel:
self.completion?(.cancel)
case .submitStarted: case .submitStarted:
self.startLoading() self.startLoading()
case .submitFinished: case .submitFinished:
self.stopLoading() self.stopLoading()
self.completion?() self.completion?(.finish)
case .submitFailed(let error): case .submitFailed(let error):
self.stopLoading() self.stopLoading()
self.showError(label: error.localizedDescription) self.showError(label: error.localizedDescription)
@ -74,32 +63,30 @@ final class BugReportCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController {
bugReportHostingController
}
func stop() { func stop() {
stopLoading() stopLoading()
} }
func toPresentable() -> AnyView {
AnyView(BugReportScreen(context: viewModel.context))
}
// MARK: - Private // MARK: - Private
/// Show an activity indicator whilst loading. static let loadingIndicatorIdentifier = "BugReportLoading"
/// - Parameters:
/// - label: The label to show on the indicator. private func startLoading(label: String = ElementL10n.loading) {
/// - isInteractionBlocking: Whether the indicator should block any user interaction. parameters.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) { type: .modal,
loadingIndicator = indicatorPresenter.present(.loading(label: label, title: label,
isInteractionBlocking: isInteractionBlocking)) persistent: true))
} }
/// Hide the currently displayed activity indicator.
private func stopLoading() { private func stopLoading() {
loadingIndicator = nil parameters.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
} }
/// Show error indicator
private func showError(label: String) { private func showError(label: String) {
statusIndicator = indicatorPresenter.present(.error(label: label)) parameters.userNotificationController.submitNotification(UserNotification(title: label))
} }
} }

View File

@ -22,6 +22,7 @@ import UIKit
// MARK: View model // MARK: View model
enum BugReportViewModelAction { enum BugReportViewModelAction {
case cancel
case submitStarted case submitStarted
case submitFinished case submitFinished
case submitFailed(error: Error) case submitFailed(error: Error)
@ -32,6 +33,7 @@ enum BugReportViewModelAction {
struct BugReportViewState: BindableState { struct BugReportViewState: BindableState {
var screenshot: UIImage? var screenshot: UIImage?
var bindings: BugReportViewStateBindings var bindings: BugReportViewStateBindings
let isModallyPresented: Bool
} }
struct BugReportViewStateBindings { struct BugReportViewStateBindings {
@ -40,6 +42,7 @@ struct BugReportViewStateBindings {
} }
enum BugReportViewAction { enum BugReportViewAction {
case cancel
case submit case submit
case toggleSendLogs case toggleSendLogs
case removeScreenshot case removeScreenshot

View File

@ -16,18 +16,41 @@
import SwiftUI import SwiftUI
@available(iOS 14, *) typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState, BugReportViewAction>
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState,
BugReportViewAction>
@available(iOS 14, *)
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
// MARK: - Properties
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
let bugReportService: BugReportServiceProtocol 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 // MARK: Private
func submitBugReport() async { private func submitBugReport() async {
callback?(.submitStarted) callback?(.submitStarted)
do { do {
var files: [URL] = [] var files: [URL] = []
@ -54,31 +77,4 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
callback?(.submitFailed(error: error)) 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
}
}
} }

View File

@ -19,6 +19,5 @@ import Foundation
@MainActor @MainActor
protocol BugReportViewModelProtocol { protocol BugReportViewModelProtocol {
var callback: ((BugReportViewModelAction) -> Void)? { get set } var callback: ((BugReportViewModelAction) -> Void)? { get set }
@available(iOS 14, *)
var context: BugReportViewModelType.Context { get } var context: BugReportViewModelType.Context { get }
} }

View File

@ -48,6 +48,16 @@ struct BugReportScreen: View {
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
} }
.navigationTitle(ElementL10n.titleActivityBugReport) .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 @ViewBuilder
static var body: some View { 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) BugReportScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait) .previewInterfaceOrientation(.portrait)
} }

View File

@ -25,42 +25,22 @@ enum FilePreviewCoordinatorAction {
case cancel case cancel
} }
final class FilePreviewCoordinator: Coordinator, Presentable { final class FilePreviewCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: FilePreviewCoordinatorParameters private let parameters: FilePreviewCoordinatorParameters
private let filePreviewHostingController: UIViewController private var viewModel: FilePreviewViewModelProtocol
private var filePreviewViewModel: FilePreviewViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((FilePreviewCoordinatorAction) -> Void)? var callback: ((FilePreviewCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: FilePreviewCoordinatorParameters) { init(parameters: FilePreviewCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title) viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
let view = FilePreviewScreen(context: viewModel.context)
filePreviewViewModel = viewModel
filePreviewHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: filePreviewHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] action in
filePreviewViewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("FilePreviewViewModel did complete with result: \(action).") MXLog.debug("FilePreviewViewModel did complete with result: \(action).")
switch action { switch action {
@ -70,26 +50,7 @@ final class FilePreviewCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func toPresentable() -> AnyView {
filePreviewHostingController AnyView(FilePreviewScreen(context: viewModel.context))
}
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
} }
} }

View File

@ -20,6 +20,8 @@ import SwiftUI
struct HomeScreenCoordinatorParameters { struct HomeScreenCoordinatorParameters {
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
let attributedStringBuilder: AttributedStringBuilderProtocol let attributedStringBuilder: AttributedStringBuilderProtocol
let bugReportService: BugReportServiceProtocol
let navigationController: NavigationController
} }
enum HomeScreenCoordinatorAction { enum HomeScreenCoordinatorAction {
@ -30,34 +32,20 @@ enum HomeScreenCoordinatorAction {
case signOut case signOut
} }
final class HomeScreenCoordinator: Coordinator, Presentable { final class HomeScreenCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: HomeScreenCoordinatorParameters private let parameters: HomeScreenCoordinatorParameters
private let hostingController: UIViewController
private var viewModel: HomeScreenViewModelProtocol private var viewModel: HomeScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((HomeScreenCoordinatorAction) -> Void)? var callback: ((HomeScreenCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: HomeScreenCoordinatorParameters) { init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
viewModel = HomeScreenViewModel(userSession: parameters.userSession, viewModel = HomeScreenViewModel(userSession: parameters.userSession,
attributedStringBuilder: parameters.attributedStringBuilder) attributedStringBuilder: parameters.attributedStringBuilder)
let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
viewModel.callback = { [weak self] action in viewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
@ -74,13 +62,21 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Public // MARK: - Public
func start() { } func start() {
if parameters.bugReportService.crashedLastRun {
func toPresentable() -> UIViewController { viewModel.presentAlert(
hostingController 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 stop() { } func toPresentable() -> AnyView {
AnyView(HomeScreen(context: viewModel.context))
}
// MARK: - Private // MARK: - Private
@ -98,11 +94,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
} }
private func presentInviteFriends() { private func presentInviteFriends() {
guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: parameters.userSession.userID).absoluteString else { parameters.navigationController.presentSheet(InviteFriendsCoordinator(userId: parameters.userSession.userID))
return
}
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
let vc = UIActivityViewController(activityItems: [shareText], applicationActivities: nil)
hostingController.present(vc, animated: true)
} }
} }

View File

@ -52,7 +52,7 @@ struct HomeScreenViewState: BindableState {
var rooms: [HomeScreenRoom] = [] var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .rooms var roomListMode: HomeScreenRoomListMode = .skeletons
var visibleRooms: [HomeScreenRoom] { var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons { if roomListMode == .skeletons {
@ -77,6 +77,8 @@ struct HomeScreenViewState: BindableState {
struct HomeScreenViewStateBindings { struct HomeScreenViewStateBindings {
var searchQuery = "" var searchQuery = ""
var alertInfo: AlertInfo<UUID>?
} }
struct HomeScreenRoom: Identifiable, Equatable { struct HomeScreenRoom: Identifiable, Equatable {
@ -92,15 +94,12 @@ struct HomeScreenRoom: Identifiable, Equatable {
var avatar: UIImage? var avatar: UIImage?
var isPlaceholder = false
static func placeholder(id: String) -> HomeScreenRoom { static func placeholder(id: String) -> HomeScreenRoom {
HomeScreenRoom(id: id, HomeScreenRoom(id: id,
name: "Placeholder room name", name: "Placeholder room name",
hasUnreads: false, hasUnreads: false,
timestamp: "Now", timestamp: "Now",
lastMessage: AttributedString("Last message"), lastMessage: AttributedString("Last message"),
avatar: UIImage(systemName: "photo"), avatar: UIImage(systemName: "photo"))
isPlaceholder: true)
} }
} }

View File

@ -111,6 +111,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
} }
} }
func presentAlert(_ alertInfo: AlertInfo<UUID>) {
state.bindings.alertInfo = alertInfo
}
// MARK: - Private // MARK: - Private
private func loadDataForRoomIdentifier(_ identifier: String) { private func loadDataForRoomIdentifier(_ identifier: String) {

View File

@ -22,4 +22,6 @@ protocol HomeScreenViewModelProtocol {
var callback: ((HomeScreenViewModelAction) -> Void)? { get set } var callback: ((HomeScreenViewModelAction) -> Void)? { get set }
var context: HomeScreenViewModelType.Context { get } var context: HomeScreenViewModelType.Context { get }
func presentAlert(_ alert: AlertInfo<UUID>)
} }

View File

@ -28,23 +28,30 @@ struct HomeScreen: View {
sessionVerificationBanner sessionVerificationBanner
} }
if context.viewState.roomListMode == .skeletons {
LazyVStack { LazyVStack {
ForEach(context.viewState.visibleRooms) { room in ForEach(context.viewState.visibleRooms) { room in
if room.isPlaceholder {
HomeScreenRoomCell(room: room, context: context) HomeScreenRoomCell(room: room, context: context)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.disabled(true) .disabled(true)
} else {
HomeScreenRoomCell(room: room, context: context)
} }
} }
.padding(.horizontal)
} else {
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context)
}
} }
.padding(.horizontal) .padding(.horizontal)
.searchable(text: $context.searchQuery) .searchable(text: $context.searchQuery)
} }
}
.disabled(context.viewState.roomListMode == .skeletons) .disabled(context.viewState.roomListMode == .skeletons)
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner) .animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
.animation(.elementDefault, value: context.viewState.roomListMode)
.ignoresSafeArea(.all, edges: .bottom) .ignoresSafeArea(.all, edges: .bottom)
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.allChats) .navigationTitle(ElementL10n.allChats)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {

View File

@ -24,42 +24,23 @@ enum MediaPlayerCoordinatorAction {
case cancel case cancel
} }
final class MediaPlayerCoordinator: Coordinator, Presentable { final class MediaPlayerCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: MediaPlayerCoordinatorParameters private let parameters: MediaPlayerCoordinatorParameters
private let mediaPlayerHostingController: UIViewController private var viewModel: MediaPlayerViewModelProtocol
private var mediaPlayerViewModel: MediaPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((MediaPlayerCoordinatorAction) -> Void)? var callback: ((MediaPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: MediaPlayerCoordinatorParameters) { init(parameters: MediaPlayerCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL) viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
let view = MediaPlayerScreen(context: viewModel.context)
mediaPlayerViewModel = viewModel
mediaPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mediaPlayerHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") MXLog.debug("Did start.")
mediaPlayerViewModel.callback = { [weak self] action in viewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("MediaPlayerViewModel did complete with result: \(action).") MXLog.debug("MediaPlayerViewModel did complete with result: \(action).")
switch action { switch action {
@ -69,26 +50,7 @@ final class MediaPlayerCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func toPresentable() -> AnyView {
mediaPlayerHostingController AnyView(MediaPlayerScreen(context: viewModel.context))
}
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
} }
} }

View File

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

View File

@ -18,12 +18,12 @@ import SwiftUI
// MARK: - Coordinator // MARK: - Coordinator
enum SplashScreenCoordinatorAction { enum OnboardingCoordinatorAction {
case login case login
} }
/// The content displayed in a single splash screen page. /// The content displayed in a single screen page.
struct SplashScreenPageContent { struct OnboardingPageContent {
let title: AttributedString let title: AttributedString
let message: String let message: String
let image: ImageAsset let image: ImageAsset
@ -31,13 +31,13 @@ struct SplashScreenPageContent {
// MARK: View model // MARK: View model
enum SplashScreenViewModelAction { enum OnboardingViewModelAction {
case login case login
} }
// MARK: View // MARK: View
struct SplashScreenViewState: BindableState { struct OnboardingViewState: BindableState {
/// The colours of the background gradient shown behind the 4 pages. /// The colours of the background gradient shown behind the 4 pages.
private let gradientColors = [ private let gradientColors = [
Color(red: 0.95, green: 0.98, blue: 0.96), 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 /// An array containing all content of the carousel pages
let content: [SplashScreenPageContent] let content: [OnboardingPageContent]
var bindings: SplashScreenBindings var bindings: OnboardingBindings
/// The background gradient for all 4 pages and the hidden page at the start of the carousel. /// The background gradient for all 4 pages and the hidden page at the start of the carousel.
var backgroundGradient: Gradient { var backgroundGradient: Gradient {
@ -69,27 +69,27 @@ struct SplashScreenViewState: BindableState {
let page4Title = locale.identifier.hasPrefix("en") ? "Cut the slack from teams." : ElementL10n.ftueAuthCarouselWorkplaceTitle let page4Title = locale.identifier.hasPrefix("en") ? "Cut the slack from teams." : ElementL10n.ftueAuthCarouselWorkplaceTitle
content = [ content = [
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."), OnboardingPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselSecureBody, message: ElementL10n.ftueAuthCarouselSecureBody,
image: Asset.Images.splashScreenPage1), image: Asset.Images.onboardingScreenPage1),
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."), OnboardingPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselControlBody, message: ElementL10n.ftueAuthCarouselControlBody,
image: Asset.Images.splashScreenPage2), image: Asset.Images.onboardingScreenPage2),
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."), OnboardingPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselEncryptedBody, message: ElementL10n.ftueAuthCarouselEncryptedBody,
image: Asset.Images.splashScreenPage3), image: Asset.Images.onboardingScreenPage3),
SplashScreenPageContent(title: page4Title.tinting("."), OnboardingPageContent(title: page4Title.tinting("."),
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName), message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
image: Asset.Images.splashScreenPage4) image: Asset.Images.onboardingScreenPage4)
] ]
bindings = SplashScreenBindings() bindings = OnboardingBindings()
} }
} }
struct SplashScreenBindings { struct OnboardingBindings {
var pageIndex = 0 var pageIndex = 0
} }
enum SplashScreenViewAction { enum OnboardingViewAction {
case login case login
} }

View File

@ -17,26 +17,26 @@
import Combine import Combine
import SwiftUI import SwiftUI
typealias SplashScreenViewModelType = StateStoreViewModel<SplashScreenViewState, SplashScreenViewAction> typealias OnboardingViewModelType = StateStoreViewModel<OnboardingViewState, OnboardingViewAction>
class SplashScreenViewModel: SplashScreenViewModelType, SplashScreenViewModelProtocol { class OnboardingViewModel: OnboardingViewModelType, OnboardingViewModelProtocol {
// MARK: - Properties // MARK: - Properties
// MARK: Private // MARK: Private
// MARK: Public // MARK: Public
var callback: ((SplashScreenViewModelAction) -> Void)? var callback: ((OnboardingViewModelAction) -> Void)?
// MARK: - Setup // MARK: - Setup
init() { init() {
super.init(initialViewState: SplashScreenViewState()) super.init(initialViewState: OnboardingViewState())
} }
// MARK: - Public // MARK: - Public
override func process(viewAction: SplashScreenViewAction) async { override func process(viewAction: OnboardingViewAction) async {
switch viewAction { switch viewAction {
case .login: case .login:
callback?(.login) callback?(.login)

View File

@ -17,7 +17,7 @@
import Foundation import Foundation
@MainActor @MainActor
protocol SplashScreenViewModelProtocol { protocol OnboardingViewModelProtocol {
var callback: ((SplashScreenViewModelAction) -> Void)? { get set } var callback: ((OnboardingViewModelAction) -> Void)? { get set }
var context: SplashScreenViewModelType.Context { get } var context: OnboardingViewModelType.Context { get }
} }

View File

@ -16,7 +16,7 @@
import SwiftUI import SwiftUI
struct SplashScreenPageIndicator: View { struct OnboardingPageIndicator: View {
// MARK: - Properties // MARK: - Properties
// MARK: Public // MARK: Public

View File

@ -16,13 +16,13 @@
import SwiftUI import SwiftUI
struct SplashScreenPageView: View { struct OnboardingPageView: View {
// MARK: - Properties // MARK: - Properties
// MARK: Public // MARK: Public
/// The content that this page should display. /// The content that this page should display.
let content: SplashScreenPageContent let content: OnboardingPageContent
// MARK: - Views // MARK: - Views
@ -54,11 +54,11 @@ struct SplashScreenPageView: View {
} }
} }
struct SplashScreenPage_Previews: PreviewProvider { struct OnboardingPage_Previews: PreviewProvider {
static let content = SplashScreenViewState().content static let content = OnboardingViewState().content
static var previews: some View { static var previews: some View {
ForEach(0..<content.count, id: \.self) { index in ForEach(0..<content.count, id: \.self) { index in
SplashScreenPageView(content: content[index]) OnboardingPageView(content: content[index])
} }
} }
} }

View File

@ -17,8 +17,8 @@
import DesignKit import DesignKit
import SwiftUI import SwiftUI
/// The splash screen shown at the beginning of the onboarding flow. /// The screen shown at the beginning of the onboarding flow.
struct SplashScreen: View { struct OnboardingScreen: View {
// MARK: - Properties // MARK: - Properties
// MARK: Private // MARK: Private
@ -36,7 +36,7 @@ struct SplashScreen: View {
// MARK: Public // MARK: Public
@ObservedObject var context: SplashScreenViewModel.Context @ObservedObject var context: OnboardingViewModel.Context
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@ -47,12 +47,12 @@ struct SplashScreen: View {
// The main content of the carousel // The main content of the carousel
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
// Add a hidden page at the start of the carousel duplicating the content of the last page // 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) .frame(width: geometry.size.width)
.accessibilityIdentifier("hiddenPage") .accessibilityIdentifier("hiddenPage")
ForEach(0..<pageCount, id: \.self) { index in ForEach(0..<pageCount, id: \.self) { index in
SplashScreenPageView(content: context.viewState.content[index]) OnboardingPageView(content: context.viewState.content[index])
.frame(width: geometry.size.width) .frame(width: geometry.size.width)
} }
} }
@ -60,7 +60,7 @@ struct SplashScreen: View {
Spacer() Spacer()
SplashScreenPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex) OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
.frame(width: geometry.size.width) .frame(width: geometry.size.width)
.padding(.bottom) .padding(.bottom)
@ -213,11 +213,11 @@ struct SplashScreen: View {
// MARK: - Previews // MARK: - Previews
struct SplashScreen_Previews: PreviewProvider { struct OnboardingScreen_Previews: PreviewProvider {
static let viewModel = SplashScreenViewModel() static let viewModel = OnboardingViewModel()
static var previews: some View { static var previews: some View {
SplashScreen(context: viewModel.context) OnboardingScreen(context: viewModel.context)
.tint(.element.accent) .tint(.element.accent)
} }
} }

View File

@ -14,21 +14,19 @@
// limitations under the License. // limitations under the License.
// //
import Foundation import SwiftUI
/// Structure used to pass modules to routers with pop completion blocks. struct InviteFriendsCoordinator: CoordinatorProtocol {
struct NavigationModule { let userId: String
/// Actual presentable of the module
let presentable: Presentable
/// Block to be called when the module is popped func toPresentable() -> AnyView {
let popCompletion: (() -> Void)? guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: userId).absoluteString else {
return AnyView(EmptyView())
} }
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
// MARK: - CustomStringConvertible return AnyView(UIActivityViewControllerWrapper(activityItems: [shareText])
.presentationDetents([.medium])
extension NavigationModule: CustomStringConvertible { .ignoresSafeArea())
var description: String {
"NavigationModule: \(presentable), pop completion: \(String(describing: popCompletion))"
} }
} }

View File

@ -14,15 +14,12 @@
// limitations under the License. // limitations under the License.
// //
import Foundation import SwiftUI
struct ToastViewState { struct SplashScreenCoordinator: CoordinatorProtocol {
enum Style { func toPresentable() -> AnyView {
case loading AnyView(
case success Image(asset: Asset.Images.appLogo)
case error )
} }
let style: Style
let label: String
} }

View File

@ -14,15 +14,15 @@
// limitations under the License. // limitations under the License.
// //
import Foundation import SwiftUI
/// A request used to create an underlying `UserIndicator`, allowing clients to only specify the visual aspects of an indicator. struct UIActivityViewControllerWrapper: UIViewControllerRepresentable {
public struct UserIndicatorRequest { var activityItems: [Any]
internal let presenter: UserIndicatorViewPresentable var applicationActivities: [UIActivity]?
internal let dismissal: UserIndicatorDismissal
public init(presenter: UserIndicatorViewPresentable, dismissal: UserIndicatorDismissal) { func makeUIViewController(context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) -> UIActivityViewController {
self.presenter = presenter UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
self.dismissal = dismissal
} }
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) { }
} }

View File

@ -17,49 +17,33 @@
import SwiftUI import SwiftUI
struct RoomScreenCoordinatorParameters { struct RoomScreenCoordinatorParameters {
let navigationRouter: NavigationRouterType let navigationController: NavigationController
let timelineController: RoomTimelineControllerProtocol let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol let mediaProvider: MediaProviderProtocol
let roomName: String? let roomName: String?
let roomAvatarUrl: String? let roomAvatarUrl: String?
} }
final class RoomScreenCoordinator: Coordinator, Presentable { final class RoomScreenCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: RoomScreenCoordinatorParameters private let parameters: RoomScreenCoordinatorParameters
private let roomScreenHostingController: UIViewController
private var roomScreenViewModel: RoomScreenViewModelProtocol
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public private var viewModel: RoomScreenViewModelProtocol
private var navigationController: NavigationController { parameters.navigationController }
// Must be used only internally
var childCoordinators: [Coordinator] = []
// MARK: - Setup
init(parameters: RoomScreenCoordinatorParameters) { init(parameters: RoomScreenCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(), timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
roomName: parameters.roomName, roomName: parameters.roomName,
roomAvatarUrl: parameters.roomAvatarUrl) roomAvatarUrl: parameters.roomAvatarUrl)
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
roomScreenHostingController = UIHostingController(rootView: view)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] result in
roomScreenViewModel.callback = { [weak self] result in
guard let self else { return } guard let self else { return }
MXLog.debug("RoomScreenViewModel did complete with result: \(result).") MXLog.debug("RoomScreenViewModel did complete with result: \(result).")
switch result { switch result {
@ -71,12 +55,12 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func stop() {
roomScreenHostingController viewModel.stop()
} }
func stop() { func toPresentable() -> AnyView {
roomScreenViewModel.stop() AnyView(RoomScreen(context: viewModel.context))
} }
// MARK: - Private // MARK: - Private
@ -84,32 +68,20 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
private func displayVideo(for videoURL: URL) { private func displayVideo(for videoURL: URL) {
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL) let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
let coordinator = VideoPlayerCoordinator(parameters: params) let coordinator = VideoPlayerCoordinator(parameters: params)
coordinator.callback = { [weak self, weak coordinator] _ in coordinator.callback = { [weak self] _ in
guard let self, let coordinator = coordinator else { return } self?.navigationController.pop()
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
} }
add(childCoordinator: coordinator) navigationController.push(coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
private func displayFile(for fileURL: URL, with title: String?) { private func displayFile(for fileURL: URL, with title: String?) {
let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title) let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title)
let coordinator = FilePreviewCoordinator(parameters: params) let coordinator = FilePreviewCoordinator(parameters: params)
coordinator.callback = { [weak self, weak coordinator] _ in coordinator.callback = { [weak self] _ in
guard let self, let coordinator = coordinator else { return } self?.navigationController.pop()
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
} }
add(childCoordinator: coordinator) navigationController.push(coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} }
} }

View File

@ -20,37 +20,22 @@ struct SessionVerificationCoordinatorParameters {
let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol
} }
final class SessionVerificationCoordinator: Coordinator, Presentable { final class SessionVerificationCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: SessionVerificationCoordinatorParameters private let parameters: SessionVerificationCoordinatorParameters
private let sessionVerificationHostingController: UIViewController private var viewModel: SessionVerificationViewModelProtocol
private var sessionVerificationViewModel: SessionVerificationViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (() -> Void)? var callback: (() -> Void)?
// MARK: - Setup
init(parameters: SessionVerificationCoordinatorParameters) { init(parameters: SessionVerificationCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy) viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
let view = SessionVerificationScreen(context: viewModel.context)
sessionVerificationViewModel = viewModel
sessionVerificationHostingController = UIHostingController(rootView: view)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] action in
sessionVerificationViewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
@ -60,9 +45,7 @@ final class SessionVerificationCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func toPresentable() -> AnyView {
sessionVerificationHostingController AnyView(SessionVerificationScreen(context: viewModel.context))
} }
func stop() { }
} }

View File

@ -17,7 +17,8 @@
import SwiftUI import SwiftUI
struct SettingsCoordinatorParameters { struct SettingsCoordinatorParameters {
let navigationRouter: NavigationRouterType let navigationController: NavigationController
let userNotificationController: UserNotificationControllerProtocol
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
let bugReportService: BugReportServiceProtocol let bugReportService: BugReportServiceProtocol
} }
@ -27,25 +28,10 @@ enum SettingsCoordinatorAction {
case logout case logout
} }
final class SettingsCoordinator: Coordinator, Presentable { final class SettingsCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: SettingsCoordinatorParameters private let parameters: SettingsCoordinatorParameters
private let settingsHostingController: UIViewController private var viewModel: SettingsViewModelProtocol
private var settingsViewModel: SettingsViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((SettingsCoordinatorAction) -> Void)? var callback: ((SettingsCoordinatorAction) -> Void)?
// MARK: - Setup // MARK: - Setup
@ -53,14 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
init(parameters: SettingsCoordinatorParameters) { init(parameters: SettingsCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = SettingsViewModel(withUserSession: parameters.userSession) viewModel = SettingsViewModel(withUserSession: parameters.userSession)
let view = SettingsScreen(context: viewModel.context) viewModel.callback = { [weak self] result in
settingsViewModel = viewModel
settingsHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: settingsHostingController)
settingsViewModel.callback = { [weak self] result in
guard let self else { return } guard let self else { return }
MXLog.debug("SettingsViewModel did complete with result: \(result).") MXLog.debug("SettingsViewModel did complete with result: \(result).")
switch result { switch result {
@ -80,16 +60,10 @@ final class SettingsCoordinator: Coordinator, Presentable {
// MARK: - Public // MARK: - Public
func start() { func toPresentable() -> AnyView {
// no-op AnyView(SettingsScreen(context: viewModel.context))
} }
func toPresentable() -> UIViewController {
settingsHostingController
}
func stop() { }
// MARK: - Private // MARK: - Private
private func toggleAnalytics() { private func toggleAnalytics() {
@ -102,39 +76,25 @@ final class SettingsCoordinator: Coordinator, Presentable {
private func presentBugReportScreen() { private func presentBugReportScreen() {
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService, let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
screenshot: nil) userNotificationController: parameters.userNotificationController,
screenshot: nil,
isModallyPresented: false)
let coordinator = BugReportCoordinator(parameters: params) let coordinator = BugReportCoordinator(parameters: params)
coordinator.completion = { [weak self, weak coordinator] in coordinator.completion = { [weak self] result in
guard let self, let coordinator = coordinator else { return } switch result {
self.parameters.navigationRouter.popModule(animated: true) case .finish:
self.remove(childCoordinator: coordinator) self?.showSuccess(label: ElementL10n.done)
self.showSuccess(label: ElementL10n.done) default:
break
} }
add(childCoordinator: coordinator) self?.parameters.navigationController.pop()
coordinator.start()
navigationRouter.push(coordinator, animated: true) { [weak self] in
guard let self else { return }
self.remove(childCoordinator: coordinator)
}
} }
/// Show an activity indicator whilst loading. parameters.navigationController.push(coordinator)
/// - 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) { private func showSuccess(label: String) {
statusIndicator = indicatorPresenter.present(.success(label: label)) parameters.userNotificationController.submitNotification(UserNotification(title: label))
} }
} }

View File

@ -16,10 +16,8 @@
import SwiftUI import SwiftUI
@available(iOS 14, *) typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState, SettingsViewAction>
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState,
SettingsViewAction>
@available(iOS 14, *)
class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol { class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
// MARK: - Properties // MARK: - Properties

View File

@ -19,6 +19,5 @@ import Foundation
@MainActor @MainActor
protocol SettingsViewModelProtocol { protocol SettingsViewModelProtocol {
var callback: ((SettingsViewModelAction) -> Void)? { get set } var callback: ((SettingsViewModelAction) -> Void)? { get set }
@available(iOS 14, *)
var context: SettingsViewModelType.Context { get } var context: SettingsViewModelType.Context { get }
} }

View File

@ -1,19 +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 UIKit
class SplashViewController: UIViewController { }

View File

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

View File

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

View File

@ -25,45 +25,24 @@ enum VideoPlayerCoordinatorAction {
case cancel case cancel
} }
final class VideoPlayerCoordinator: Coordinator, Presentable { final class VideoPlayerCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: VideoPlayerCoordinatorParameters private let parameters: VideoPlayerCoordinatorParameters
private let videoPlayerHostingController: UIViewController private var viewModel: VideoPlayerViewModelProtocol
private var videoPlayerViewModel: VideoPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((VideoPlayerCoordinatorAction) -> Void)? var callback: ((VideoPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: VideoPlayerCoordinatorParameters) { init(parameters: VideoPlayerCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL) viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
let view = VideoPlayerScreen(context: viewModel.context)
videoPlayerViewModel = viewModel
videoPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.")
configureAudioSession(.sharedInstance()) configureAudioSession(.sharedInstance())
videoPlayerViewModel.callback = { [weak self] action in viewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).") MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
switch action { switch action {
@ -73,12 +52,8 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func toPresentable() -> AnyView {
videoPlayerHostingController AnyView(VideoPlayerScreen(context: viewModel.context))
}
func stop() {
stopLoading()
} }
// MARK: - Private // MARK: - Private
@ -93,17 +68,4 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
MXLog.debug("Configure audio session failed: \(error)") 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
}
} }

View File

@ -14,27 +14,25 @@
// limitations under the License. // limitations under the License.
// //
import UIKit import SwiftUI
enum UserSessionFlowCoordinatorAction { enum UserSessionFlowCoordinatorAction {
case signOut case signOut
} }
class UserSessionFlowCoordinator: Coordinator { class UserSessionFlowCoordinator: CoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine private let stateMachine: UserSessionFlowCoordinatorStateMachine
private let userSession: UserSessionProtocol private let userSession: UserSessionProtocol
private let navigationRouter: NavigationRouterType private let navigationController: NavigationController
private let bugReportService: BugReportServiceProtocol private let bugReportService: BugReportServiceProtocol
var childCoordinators: [Coordinator] = []
var callback: ((UserSessionFlowCoordinatorAction) -> Void)? var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
init(userSession: UserSessionProtocol, navigationRouter: NavigationRouterType, bugReportService: BugReportServiceProtocol) { init(userSession: UserSessionProtocol, navigationController: NavigationController, bugReportService: BugReportServiceProtocol) {
stateMachine = UserSessionFlowCoordinatorStateMachine() stateMachine = UserSessionFlowCoordinatorStateMachine()
self.userSession = userSession self.userSession = userSession
self.navigationRouter = navigationRouter self.navigationController = navigationController
self.bugReportService = bugReportService self.bugReportService = bugReportService
setupStateMachine() setupStateMachine()
@ -45,8 +43,6 @@ class UserSessionFlowCoordinator: Coordinator {
stateMachine.processEvent(.start) stateMachine.processEvent(.start)
} }
func stop() { }
// MARK: - Private // MARK: - Private
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
@ -60,23 +56,23 @@ class UserSessionFlowCoordinator: Coordinator {
case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)): case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)):
self.presentRoomWithIdentifier(roomId) self.presentRoomWithIdentifier(roomId)
case(.roomScreen(let roomId), .dismissedRoomScreen, .homeScreen): case(.roomScreen, .dismissedRoomScreen, .homeScreen):
self.tearDownDismissedRoomScreen(roomId) break
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen): case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentSessionVerification() self.presentSessionVerification()
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen): case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
self.tearDownDismissedSessionVerificationScreen() break
case (.homeScreen, .showSettingsScreen, .settingsScreen): case (.homeScreen, .showSettingsScreen, .settingsScreen):
self.presentSettingsScreen() self.presentSettingsScreen()
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen): case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
self.dismissSettingsScreen() break
case (.homeScreen, .feedbackScreen, .feedbackScreen): case (.homeScreen, .feedbackScreen, .feedbackScreen):
self.presentFeedbackScreen() self.presentFeedbackScreen()
case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen): case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen):
self.dismissFeedbackScreen() break
case (_, .resignActive, .suspended): case (_, .resignActive, .suspended):
self.pause() self.pause()
@ -108,7 +104,9 @@ class UserSessionFlowCoordinator: Coordinator {
userSession.clientProxy.startSync() userSession.clientProxy.startSync()
let parameters = HomeScreenCoordinatorParameters(userSession: userSession, let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder()) attributedStringBuilder: AttributedStringBuilder(),
bugReportService: bugReportService,
navigationController: navigationController)
let coordinator = HomeScreenCoordinator(parameters: parameters) let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in coordinator.callback = { [weak self] action in
@ -128,12 +126,7 @@ class UserSessionFlowCoordinator: Coordinator {
} }
} }
add(childCoordinator: coordinator) navigationController.setRootCoordinator(coordinator)
navigationRouter.setRootModule(coordinator)
if bugReportService.crashedLastRun {
showCrashPopup()
}
} }
// MARK: Rooms // MARK: Rooms
@ -158,69 +151,48 @@ class UserSessionFlowCoordinator: Coordinator {
mediaProvider: userSession.mediaProvider, mediaProvider: userSession.mediaProvider,
roomProxy: roomProxy) roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
timelineController: timelineController, timelineController: timelineController,
mediaProvider: userSession.mediaProvider, mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name, roomName: roomProxy.displayName ?? roomProxy.name,
roomAvatarUrl: roomProxy.avatarURL) roomAvatarUrl: roomProxy.avatarURL)
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
add(childCoordinator: coordinator) navigationController.push(coordinator) { [weak self] in
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
guard let self else { return } guard let self else { return }
self.stateMachine.processEvent(.dismissedRoomScreen) self.stateMachine.processEvent(.dismissedRoomScreen)
} }
} }
} }
private func tearDownDismissedRoomScreen(_ roomId: String) {
guard let coordinator = childCoordinators.last as? RoomScreenCoordinator else {
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
}
remove(childCoordinator: coordinator)
}
// MARK: Settings // MARK: Settings
private func presentSettingsScreen() { private func presentSettingsScreen() {
let navController = ElementNavigationController() let settingsNavigationController = NavigationController()
let newNavigationRouter = NavigationRouter(navigationController: navController)
let parameters = SettingsCoordinatorParameters(navigationRouter: newNavigationRouter, let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationController)
let parameters = SettingsCoordinatorParameters(navigationController: settingsNavigationController,
userNotificationController: userNotificationController,
userSession: userSession, userSession: userSession,
bugReportService: bugReportService) bugReportService: bugReportService)
let coordinator = SettingsCoordinator(parameters: parameters) let settingsCoordinator = SettingsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in settingsCoordinator.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
case .dismiss: case .dismiss:
self.dismissSettingsScreen() self.navigationController.dismissSheet()
case .logout: case .logout:
self.dismissSettingsScreen() self.navigationController.dismissSheet()
self.callback?(.signOut) self.callback?(.signOut)
} }
} }
add(childCoordinator: coordinator) settingsNavigationController.setRootCoordinator(settingsCoordinator)
coordinator.start()
navController.viewControllers = [coordinator.toPresentable()] navigationController.presentSheet(userNotificationController) { [weak self] in
navigationRouter.present(navController, animated: true) self?.stateMachine.processEvent(.dismissedSettingsScreen)
} }
@objc
private func dismissSettingsScreen() {
MXLog.debug("dismissSettingsScreen")
guard let coordinator = childCoordinators.first(where: { $0 is SettingsCoordinator }) else {
return
}
navigationRouter.dismissModule()
remove(childCoordinator: coordinator)
stateMachine.processEvent(.dismissedSettingsScreen)
} }
// MARK: Session verification // MARK: Session verification
@ -235,69 +207,35 @@ class UserSessionFlowCoordinator: Coordinator {
let coordinator = SessionVerificationCoordinator(parameters: parameters) let coordinator = SessionVerificationCoordinator(parameters: parameters)
coordinator.callback = { [weak self] in coordinator.callback = { [weak self] in
self?.navigationRouter.dismissModule() self?.navigationController.dismissSheet()
}
navigationController.presentSheet(coordinator) { [weak self] in
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen) self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
} }
add(childCoordinator: coordinator)
navigationRouter.present(coordinator)
coordinator.start()
}
private func tearDownDismissedSessionVerificationScreen() {
guard let coordinator = childCoordinators.last as? SessionVerificationCoordinator else {
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
}
remove(childCoordinator: coordinator)
} }
// MARK: Bug reporting // MARK: Bug reporting
private func showCrashPopup() { private func presentFeedbackScreen(for image: UIImage? = nil) {
let alert = UIAlertController(title: nil, let feedbackNavigationController = NavigationController()
message: ElementL10n.sendBugReportAppCrashed,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: ElementL10n.no, style: .cancel)) let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationController)
alert.addAction(UIAlertAction(title: ElementL10n.yes, style: .default) { [weak self] _ in
self?.stateMachine.processEvent(.feedbackScreen)
})
navigationRouter.present(alert, animated: true) let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
userNotificationController: userNotificationController,
screenshot: image,
isModallyPresented: true)
let coordinator = BugReportCoordinator(parameters: parameters)
coordinator.completion = { [weak self] _ in
self?.navigationController.dismissSheet()
} }
private func presentFeedbackScreen(for image: UIImage? = nil) { feedbackNavigationController.setRootCoordinator(coordinator)
let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
screenshot: image) navigationController.presentSheet(userNotificationController) { [weak self] in
let coordinator = BugReportCoordinator(parameters: parameters)
coordinator.completion = { [weak self] in
self?.stateMachine.processEvent(.dismissedFeedbackScreen) self?.stateMachine.processEvent(.dismissedFeedbackScreen)
} }
add(childCoordinator: coordinator)
coordinator.start()
let navController = ElementNavigationController(rootViewController: coordinator.toPresentable())
navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
target: self,
action: #selector(handleFeedbackScreenCancellation))
navController.isModalInPresentation = true
navigationRouter.present(navController, animated: true)
}
@objc
private func handleFeedbackScreenCancellation() {
stateMachine.processEvent(.dismissedFeedbackScreen)
}
private func dismissFeedbackScreen() {
guard let coordinator = childCoordinators.first(where: { $0 is BugReportCoordinator }) else {
return
}
navigationRouter.dismissModule()
remove(childCoordinator: coordinator)
} }
// MARK: - Application State // MARK: - Application State

View File

@ -17,74 +17,58 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
class UITestsAppCoordinator: Coordinator { class UITestsAppCoordinator: CoordinatorProtocol {
private let window: UIWindow private let navigationController: NavigationController
private let mainNavigationController: ElementNavigationController
private let navigationRouter: NavigationRouter
private var hostingController: UIViewController?
var childCoordinators: [Coordinator] = []
init() { init() {
mainNavigationController = ElementNavigationController() navigationController = NavigationController()
mainNavigationController.navigationBar.prefersLargeTitles = true }
navigationRouter = NavigationRouter(navigationController: mainNavigationController)
window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = mainNavigationController
window.tintColor = .element.accent
UIView.setAnimationsEnabled(false)
func start() {
let screens = mockScreens() let screens = mockScreens()
let rootCoordinator = UITestsRootCoordinator(mockScreens: screens) { id in
let rootView = UITestsRootView(mockScreens: screens) { id in
guard let screen = screens.first(where: { $0.id == id }) else { guard let screen = screens.first(where: { $0.id == id }) else {
fatalError() fatalError()
} }
screen.coordinator.start() self.navigationController.setRootCoordinator(screen.coordinator)
self.navigationRouter.setRootModule(screen.coordinator)
} }
let hostingController = UIHostingController(rootView: rootView) navigationController.setRootCoordinator(rootCoordinator)
self.hostingController = hostingController
mainNavigationController.setViewControllers([hostingController], animated: false)
} }
func start() { func toPresentable() -> AnyView {
window.makeKeyAndVisible() navigationController.toPresentable()
} }
private func mockScreens() -> [MockScreen] { private func mockScreens() -> [MockScreen] {
UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationRouter: navigationRouter) } UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationController: navigationController) }
} }
func stop() { }
} }
@MainActor @MainActor
class MockScreen: Identifiable { class MockScreen: Identifiable {
let id: UITestScreenIdentifier let id: UITestScreenIdentifier
let navigationRouter: NavigationRouter let navigationController: NavigationController
lazy var coordinator: Coordinator & Presentable = { lazy var coordinator: CoordinatorProtocol = {
switch id { switch id {
case .login: case .login:
return LoginCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), return LoginCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
navigationRouter: navigationRouter)) navigationController: navigationController))
case .serverSelection: case .serverSelection:
return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
hasModalPresentation: true)) userNotificationController: MockUserNotificationController(),
isModallyPresented: true))
case .serverSelectionNonModal: case .serverSelectionNonModal:
return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
hasModalPresentation: false)) userNotificationController: MockUserNotificationController(),
isModallyPresented: false))
case .analyticsPrompt: case .analyticsPrompt:
return AnalyticsPromptCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"), return AnalyticsPromptCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"),
mediaProvider: MockMediaProvider()))) mediaProvider: MockMediaProvider())))
case .authenticationFlow: case .authenticationFlow:
return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(), return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(),
navigationRouter: navigationRouter) navigationController: navigationController)
case .softLogout: case .softLogout:
let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org", let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org",
homeserverName: "matrix.org", homeserverName: "matrix.org",
@ -100,29 +84,37 @@ class MockScreen: Identifiable {
case .home: case .home:
let session = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:matrix.org"), let session = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:matrix.org"),
mediaProvider: MockMediaProvider()) mediaProvider: MockMediaProvider())
return HomeScreenCoordinator(parameters: .init(userSession: session, attributedStringBuilder: AttributedStringBuilder())) return HomeScreenCoordinator(parameters: .init(userSession: session,
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: MockBugReportService(),
navigationController: navigationController))
case .settings: case .settings:
return SettingsCoordinator(parameters: .init(navigationRouter: navigationRouter, return SettingsCoordinator(parameters: .init(navigationController: navigationController,
userNotificationController: MockUserNotificationController(),
userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"), userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"),
mediaProvider: MockMediaProvider()), mediaProvider: MockMediaProvider()),
bugReportService: MockBugReportService())) bugReportService: MockBugReportService()))
case .bugReport: case .bugReport:
return BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(), return BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(),
screenshot: nil)) userNotificationController: MockUserNotificationController(),
screenshot: nil,
isModallyPresented: false))
case .bugReportWithScreenshot: case .bugReportWithScreenshot:
return BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(), return BugReportCoordinator(parameters: .init(bugReportService: MockBugReportService(),
screenshot: Asset.Images.appLogo.image)) userNotificationController: MockUserNotificationController(),
screenshot: Asset.Images.appLogo.image,
isModallyPresented: false))
case .splash: case .splash:
return SplashScreenCoordinator() return OnboardingCoordinator()
case .roomPlainNoAvatar: case .roomPlainNoAvatar:
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Some room name", roomName: "Some room name",
roomAvatarUrl: nil) roomAvatarUrl: nil)
return RoomScreenCoordinator(parameters: parameters) return RoomScreenCoordinator(parameters: parameters)
case .roomEncryptedWithAvatar: case .roomEncryptedWithAvatar:
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter, let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Some room name", roomName: "Some room name",
@ -134,8 +126,8 @@ class MockScreen: Identifiable {
} }
}() }()
init(id: UITestScreenIdentifier, navigationRouter: NavigationRouter) { init(id: UITestScreenIdentifier, navigationController: NavigationController) {
self.id = id self.id = id
self.navigationRouter = navigationRouter self.navigationController = navigationController
} }
} }

View File

@ -16,12 +16,15 @@
import SwiftUI import SwiftUI
struct UITestsRootView: View { struct UITestsRootCoordinator: CoordinatorProtocol {
let mockScreens: [MockScreen] let mockScreens: [MockScreen]
var selectionCallback: ((UITestScreenIdentifier) -> Void)? var selectionCallback: ((UITestScreenIdentifier) -> Void)?
var body: some View { func toPresentable() -> AnyView {
NavigationView { AnyView(body)
}
private var body: some View {
List(mockScreens) { coordinator in List(mockScreens) { coordinator in
Button(coordinator.id.description) { Button(coordinator.id.description) {
selectionCallback?(coordinator.id) selectionCallback?(coordinator.id)
@ -29,7 +32,6 @@ struct UITestsRootView: View {
.accessibilityIdentifier(coordinator.id.rawValue) .accessibilityIdentifier(coordinator.id.rawValue)
} }
.listStyle(.plain) .listStyle(.plain)
}
.navigationTitle("Screens") .navigationTitle("Screens")
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
} }

View File

@ -5,8 +5,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/vector-im/element-design-tokens.git", "location" : "https://github.com/vector-im/element-design-tokens.git",
"state" : { "state" : {
"revision" : "4aafdc25ca0e322c0de930d4ec86121f5503023e", "revision" : "63e40f10b336c136d6d05f7967e4565e37d3d760",
"version" : "0.0.1" "version" : "0.0.3"
} }
}, },
{ {

View File

@ -25,42 +25,22 @@ enum TemplateCoordinatorAction {
case cancel case cancel
} }
final class TemplateCoordinator: Coordinator, Presentable { final class TemplateCoordinator: CoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let parameters: TemplateCoordinatorParameters private let parameters: TemplateCoordinatorParameters
private let templateHostingController: UIViewController private var viewModel: TemplateViewModelProtocol
private var templateViewModel: TemplateViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((TemplateCoordinatorAction) -> Void)? var callback: ((TemplateCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: TemplateCoordinatorParameters) { init(parameters: TemplateCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = TemplateViewModel(promptType: parameters.promptType) viewModel = TemplateViewModel(promptType: parameters.promptType)
let view = TemplateScreen(context: viewModel.context)
templateViewModel = viewModel
templateHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: templateHostingController)
} }
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("Did start.") viewModel.callback = { [weak self] action in
templateViewModel.callback = { [weak self] action in
guard let self else { return } guard let self else { return }
MXLog.debug("TemplateViewModel did complete with result: \(action).") MXLog.debug("TemplateViewModel did complete with result: \(action).")
switch action { switch action {
@ -72,26 +52,7 @@ final class TemplateCoordinator: Coordinator, Presentable {
} }
} }
func toPresentable() -> UIViewController { func toPresentable() -> AnyView {
templateHostingController AnyView(TemplateScreen(context: viewModel.context))
}
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
} }
} }

View File

@ -17,7 +17,7 @@
import XCTest import XCTest
@MainActor @MainActor
class SplashScreenUITests: XCTestCase { class OnboardingUITests: XCTestCase {
func testInitialStateComponents() { func testInitialStateComponents() {
let app = Application.launch() let app = Application.launch()
app.goToScreenWithIdentifier(.splash) app.goToScreenWithIdentifier(.splash)

View File

@ -21,7 +21,7 @@ import XCTest
@MainActor @MainActor
class BugReportViewModelTests: XCTestCase { class BugReportViewModelTests: XCTestCase {
func testInitialState() { func testInitialState() {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: nil) let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: nil, isModallyPresented: false)
let context = viewModel.context let context = viewModel.context
XCTAssertEqual(context.reportText, "") XCTAssertEqual(context.reportText, "")
@ -30,7 +30,7 @@ class BugReportViewModelTests: XCTestCase {
} }
func testToggleSendingLogs() async throws { func testToggleSendingLogs() async throws {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: nil) let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: nil, isModallyPresented: false)
let context = viewModel.context let context = viewModel.context
context.send(viewAction: .toggleSendLogs) context.send(viewAction: .toggleSendLogs)
@ -39,7 +39,7 @@ class BugReportViewModelTests: XCTestCase {
} }
func testClearScreenshot() async throws { func testClearScreenshot() async throws {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: UIImage.actions) let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: UIImage.actions, isModallyPresented: false)
let context = viewModel.context let context = viewModel.context
context.send(viewAction: .removeScreenshot) context.send(viewAction: .removeScreenshot)

Some files were not shown because too many files have changed in this diff Show More