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

View File

@ -16,7 +16,7 @@
import Combine
import MatrixRustSDK
import UIKit
import SwiftUI
struct ServiceLocator {
fileprivate static var serviceLocator: ServiceLocator?
@ -28,19 +28,12 @@ struct ServiceLocator {
return serviceLocator
}
let userIndicatorPresenter: UserIndicatorTypePresenter
let userNotificationController: UserNotificationControllerProtocol
}
class AppCoordinator: Coordinator {
private let window: UIWindow
class AppCoordinator: CoordinatorProtocol {
private let stateMachine: AppCoordinatorStateMachine
private let mainNavigationController: UINavigationController
private let splashViewController: UIViewController
private let navigationRouter: NavigationRouter
private let navigationController: NavigationController
private let userSessionStore: UserSessionStoreProtocol
private var userSession: UserSessionProtocol! {
@ -53,34 +46,22 @@ class AppCoordinator: Coordinator {
}
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var authenticationCoordinator: AuthenticationCoordinator?
private let bugReportService: BugReportServiceProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var cancellables = Set<AnyCancellable>()
var childCoordinators: [Coordinator] = []
init() {
navigationController = NavigationController()
stateMachine = AppCoordinatorStateMachine()
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
splashViewController = SplashViewController()
navigationController.setRootCoordinator(SplashScreenCoordinator())
mainNavigationController = ElementNavigationController(rootViewController: splashViewController)
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))
ServiceLocator.serviceLocator = ServiceLocator(userNotificationController: UserNotificationController(rootCoordinator: navigationController))
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
fatalError("Should have a valid bundle identifier at this point")
@ -95,11 +76,12 @@ class AppCoordinator: Coordinator {
setupLogging()
Bundle.elementFallbackLanguage = "en"
// Benchmark.trackingEnabled = true
}
func start() {
window.makeKeyAndVisible()
stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication)
}
@ -107,6 +89,10 @@ class AppCoordinator: Coordinator {
hideLoadingIndicator()
}
func toPresentable() -> AnyView {
ServiceLocator.shared.userNotificationController.toPresentable()
}
// MARK: - Private
private func setupLogging() {
@ -192,12 +178,11 @@ class AppCoordinator: Coordinator {
private func startAuthentication() {
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
let coordinator = AuthenticationCoordinator(authenticationService: authenticationService,
navigationRouter: navigationRouter)
coordinator.delegate = self
authenticationCoordinator = AuthenticationCoordinator(authenticationService: authenticationService,
navigationController: navigationController)
authenticationCoordinator?.delegate = self
add(childCoordinator: coordinator)
coordinator.start()
authenticationCoordinator?.start()
}
private func startAuthenticationSoftLogout() {
@ -206,12 +191,12 @@ class AppCoordinator: Coordinator {
if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() {
displayName = name
}
let credentials = SoftLogoutCredentials(userId: userSession.userID,
homeserverName: userSession.homeserver,
userDisplayName: displayName,
deviceId: userSession.deviceId)
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
_ = await authenticationService.configure(for: userSession.homeserver)
@ -223,27 +208,22 @@ class AppCoordinator: Coordinator {
switch result {
case .signedIn(let session):
self.userSession = session
self.remove(childCoordinator: coordinator)
self.stateMachine.processEvent(.succeededSigningIn)
case .clearAllData:
// clear user data
self.userSessionStore.logout(userSession: self.userSession)
self.userSession = nil
self.remove(childCoordinator: coordinator)
self.startAuthentication()
}
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.setRootModule(coordinator)
navigationController.setRootCoordinator(coordinator)
}
}
private func setupUserSession() {
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
navigationRouter: navigationRouter,
navigationController: navigationController,
bugReportService: bugReportService)
userSessionFlowCoordinator.callback = { [weak self] action in
@ -280,12 +260,8 @@ class AppCoordinator: Coordinator {
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
if let presentedCoordinator = childCoordinators.first {
remove(childCoordinator: presentedCoordinator)
}
mainNavigationController.setViewControllers([splashViewController], animated: false)
navigationController.setRootCoordinator(SplashScreenCoordinator())
if isSoftLogout {
startAuthenticationSoftLogout()
} else {
@ -319,16 +295,21 @@ class AppCoordinator: Coordinator {
// MARK: Toasts and loading indicators
static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
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() {
loadingIndicator = nil
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
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 {
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
remove(childCoordinator: authenticationCoordinator)
stateMachine.processEvent(.succeededSigningIn)
}
}

View File

@ -14,26 +14,38 @@
// limitations under the License.
//
import UIKit
import SwiftUI
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
private lazy var appCoordinator: Coordinator = Tests.isRunningUITests ? UITestsAppCoordinator() : AppCoordinator()
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// use `en` as fallback language
Bundle.elementFallbackLanguage = "en"
return true
struct Application: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
private let applicationCoordinator: CoordinatorProtocol
init() {
if Tests.isRunningUITests {
applicationCoordinator = UITestsAppCoordinator()
} else {
applicationCoordinator = AppCoordinator()
}
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
if Tests.isRunningUnitTests {
return true
var body: some Scene {
WindowGroup {
if Tests.isRunningUnitTests {
EmptyView()
} else {
applicationCoordinator.toPresentable()
.tint(.element.accent)
.task {
applicationCoordinator.start()
}
}
}
appCoordinator.start()
return true
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
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 static let analyticsCheckmark = ImageAsset(name: "Images/AnalyticsCheckmark")
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 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 encryptionTrusted = ImageAsset(name: "Images/encryption_trusted")
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 SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public extension Animation {
/// Animation to be used to disable animations.
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
/// animation.
/// - Parameters:

View File

@ -31,9 +31,14 @@ struct AlertInfo<T: Hashable>: Identifiable {
/// The alert's message (optional).
var message: String?
/// 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.
var secondaryButton: (title: String, action: (() -> Void)?)?
var secondaryButton: AlertButton?
}
struct AlertButton {
let title: String
let action: (() -> Void)?
}
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 {
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.
//
@testable import ElementX
import Foundation
class UserIndicatorPresenterSpy: UserIndicatorViewPresentable {
var intel = [String]()
struct MockUserNotificationController: UserNotificationControllerProtocol {
func submitNotification(_ notification: UserNotification) { }
func present() {
intel.append(#function)
}
func retractNotificationWithId(_ id: String) { }
func dismiss() {
intel.append(#function)
}
func retractAllNotifications() { }
}

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.
//
import UIKit
import Foundation
class ElementNavigationController: UINavigationController {
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
navigationBar.topItem?.backButtonDisplayMode = .minimal
}
protocol UserNotificationControllerProtocol: CoordinatorProtocol {
func submitNotification(_ notification: UserNotification)
func retractNotificationWithId(_ id: String)
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
}
final class AnalyticsPromptCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class AnalyticsPromptCoordinator: CoordinatorProtocol {
private let parameters: AnalyticsPromptCoordinatorParameters
private let analyticsPromptHostingController: UIViewController
private var analyticsPromptViewModel: AnalyticsPromptViewModel
// MARK: Public
private var viewModel: AnalyticsPromptViewModel
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor () -> Void)?
// MARK: - Setup
init(parameters: AnalyticsPromptCoordinatorParameters) {
self.parameters = parameters
let viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
let view = AnalyticsPrompt(context: viewModel.context)
analyticsPromptViewModel = viewModel
analyticsPromptHostingController = UIHostingController(rootView: view)
viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
analyticsPromptViewModel.callback = { [weak self] result in
viewModel.callback = { [weak self] result in
MXLog.debug("AnalyticsPromptViewModel did complete with result: \(result).")
guard let self else { return }
@ -69,7 +52,7 @@ final class AnalyticsPromptCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController { analyticsPromptHostingController }
func stop() { }
func toPresentable() -> AnyView {
AnyView(AnalyticsPrompt(context: viewModel.context))
}
}

View File

@ -14,7 +14,7 @@
// limitations under the License.
//
import UIKit
import SwiftUI
@MainActor
protocol AuthenticationCoordinatorDelegate: AnyObject {
@ -22,42 +22,31 @@ protocol AuthenticationCoordinatorDelegate: AnyObject {
didLoginWithSession userSession: UserSessionProtocol)
}
class AuthenticationCoordinator: Coordinator, Presentable {
class AuthenticationCoordinator: CoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol
private let navigationRouter: NavigationRouter
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
var childCoordinators: [Coordinator] = []
private let navigationController: NavigationController
weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
navigationRouter: NavigationRouter) {
navigationController: NavigationController) {
self.authenticationService = authenticationService
self.navigationRouter = navigationRouter
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: navigationRouter.toPresentable())
self.navigationController = navigationController
}
func start() {
showSplashScreen()
showOnboarding()
}
func toPresentable() -> UIViewController {
navigationRouter.toPresentable()
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Shows the splash screen as the root view in the navigation stack.
private func showSplashScreen() {
let coordinator = SplashScreenCoordinator()
private func showOnboarding() {
let coordinator = OnboardingCoordinator()
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
@ -66,12 +55,7 @@ class AuthenticationCoordinator: Coordinator, Presentable {
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.setRootCoordinator(coordinator)
}
private func startAuthentication() async {
@ -89,7 +73,8 @@ class AuthenticationCoordinator: Coordinator, Presentable {
private func showServerSelectionScreen() {
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: false)
userNotificationController: ServiceLocator.shared.userNotificationController,
isModallyPresented: false)
let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@ -103,34 +88,24 @@ class AuthenticationCoordinator: Coordinator, Presentable {
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.push(coordinator)
}
private func showLoginScreen() {
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
navigationRouter: navigationRouter)
navigationController: navigationController)
let coordinator = LoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .signedIn(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.push(coordinator)
}
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
@ -141,22 +116,20 @@ class AuthenticationCoordinator: Coordinator, Presentable {
guard let self else { return }
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
navigationController.setRootCoordinator(coordinator)
}
/// Show a blocking activity indicator.
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
private func startLoading() {
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.loading,
persistent: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -28,13 +28,13 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)?
// MARK: - Setup
init(homeserverAddress: String, hasModalPresentation: Bool) {
init(homeserverAddress: String, isModallyPresented: Bool) {
let bindings = ServerSelectionBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: ServerSelectionViewState(bindings: bindings,
hasModalPresentation: hasModalPresentation))
isModallyPresented: isModallyPresented))
}
// MARK: - Public
override func process(viewAction: ServerSelectionViewAction) async {

View File

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

View File

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

View File

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

View File

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

View File

@ -16,18 +16,41 @@
import SwiftUI
@available(iOS 14, *)
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState,
BugReportViewAction>
@available(iOS 14, *)
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
// MARK: - Properties
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState, BugReportViewAction>
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
let bugReportService: BugReportServiceProtocol
var callback: ((BugReportViewModelAction) -> Void)?
init(bugReportService: BugReportServiceProtocol,
screenshot: UIImage?,
isModallyPresented: Bool) {
self.bugReportService = bugReportService
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
bindings: bindings,
isModallyPresented: isModallyPresented))
}
// MARK: - Public
override func process(viewAction: BugReportViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
case .submit:
await submitBugReport()
case .toggleSendLogs:
context.sendingLogsEnabled.toggle()
case .removeScreenshot:
state.screenshot = nil
}
}
// MARK: Private
func submitBugReport() async {
private func submitBugReport() async {
callback?(.submitStarted)
do {
var files: [URL] = []
@ -54,31 +77,4 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
callback?(.submitFailed(error: error))
}
}
// MARK: Public
var callback: ((BugReportViewModelAction) -> Void)?
// MARK: - Setup
init(bugReportService: BugReportServiceProtocol,
screenshot: UIImage?) {
self.bugReportService = bugReportService
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
bindings: bindings))
}
// MARK: - Public
override func process(viewAction: BugReportViewAction) async {
switch viewAction {
case .submit:
await submitBugReport()
case .toggleSendLogs:
context.sendingLogsEnabled.toggle()
case .removeScreenshot:
state.screenshot = nil
}
}
}

View File

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

View File

@ -48,6 +48,16 @@ struct BugReportScreen: View {
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
}
.navigationTitle(ElementL10n.titleActivityBugReport)
.toolbar {
if context.viewState.isModallyPresented {
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
.interactiveDismissDisabled()
}
}
@ -136,7 +146,7 @@ struct BugReport_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image, isModallyPresented: false)
BugReportScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}

View File

@ -25,42 +25,22 @@ enum FilePreviewCoordinatorAction {
case cancel
}
final class FilePreviewCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class FilePreviewCoordinator: CoordinatorProtocol {
private let parameters: FilePreviewCoordinatorParameters
private let filePreviewHostingController: UIViewController
private var filePreviewViewModel: FilePreviewViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
private var viewModel: FilePreviewViewModelProtocol
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((FilePreviewCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: FilePreviewCoordinatorParameters) {
self.parameters = parameters
let viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
let view = FilePreviewScreen(context: viewModel.context)
filePreviewViewModel = viewModel
filePreviewHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: filePreviewHostingController)
viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
filePreviewViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("FilePreviewViewModel did complete with result: \(action).")
switch action {
@ -70,26 +50,7 @@ final class FilePreviewCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
filePreviewHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
func toPresentable() -> AnyView {
AnyView(FilePreviewScreen(context: viewModel.context))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,42 +24,23 @@ enum MediaPlayerCoordinatorAction {
case cancel
}
final class MediaPlayerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class MediaPlayerCoordinator: CoordinatorProtocol {
private let parameters: MediaPlayerCoordinatorParameters
private let mediaPlayerHostingController: UIViewController
private var mediaPlayerViewModel: MediaPlayerViewModelProtocol
private var viewModel: MediaPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((MediaPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: MediaPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
let view = MediaPlayerScreen(context: viewModel.context)
mediaPlayerViewModel = viewModel
mediaPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mediaPlayerHostingController)
viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
mediaPlayerViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("MediaPlayerViewModel did complete with result: \(action).")
switch action {
@ -69,26 +50,7 @@ final class MediaPlayerCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
mediaPlayerHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
func toPresentable() -> AnyView {
AnyView(MediaPlayerScreen(context: viewModel.context))
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,15 +14,15 @@
// 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.
public struct UserIndicatorRequest {
internal let presenter: UserIndicatorViewPresentable
internal let dismissal: UserIndicatorDismissal
struct UIActivityViewControllerWrapper: UIViewControllerRepresentable {
var activityItems: [Any]
var applicationActivities: [UIActivity]?
public init(presenter: UserIndicatorViewPresentable, dismissal: UserIndicatorDismissal) {
self.presenter = presenter
self.dismissal = dismissal
func makeUIViewController(context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<UIActivityViewControllerWrapper>) { }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,5 @@ import Foundation
@MainActor
protocol SettingsViewModelProtocol {
var callback: ((SettingsViewModelAction) -> Void)? { get set }
@available(iOS 14, *)
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
}
final class VideoPlayerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class VideoPlayerCoordinator: CoordinatorProtocol {
private let parameters: VideoPlayerCoordinatorParameters
private let videoPlayerHostingController: UIViewController
private var videoPlayerViewModel: VideoPlayerViewModelProtocol
private var viewModel: VideoPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((VideoPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: VideoPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
let view = VideoPlayerScreen(context: viewModel.context)
videoPlayerViewModel = viewModel
videoPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController)
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
configureAudioSession(.sharedInstance())
videoPlayerViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
switch action {
@ -73,16 +52,12 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
}
}
func toPresentable() -> UIViewController {
videoPlayerHostingController
func toPresentable() -> AnyView {
AnyView(VideoPlayerScreen(context: viewModel.context))
}
func stop() {
stopLoading()
}
// MARK: - Private
private func configureAudioSession(_ session: AVAudioSession) {
do {
try session.setCategory(.playback,
@ -93,17 +68,4 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
MXLog.debug("Configure audio session failed: \(error)")
}
}
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
}
}

View File

@ -14,27 +14,25 @@
// limitations under the License.
//
import UIKit
import SwiftUI
enum UserSessionFlowCoordinatorAction {
case signOut
}
class UserSessionFlowCoordinator: Coordinator {
class UserSessionFlowCoordinator: CoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private let userSession: UserSessionProtocol
private let navigationRouter: NavigationRouterType
private let navigationController: NavigationController
private let bugReportService: BugReportServiceProtocol
var childCoordinators: [Coordinator] = []
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
init(userSession: UserSessionProtocol, navigationRouter: NavigationRouterType, bugReportService: BugReportServiceProtocol) {
init(userSession: UserSessionProtocol, navigationController: NavigationController, bugReportService: BugReportServiceProtocol) {
stateMachine = UserSessionFlowCoordinatorStateMachine()
self.userSession = userSession
self.navigationRouter = navigationRouter
self.navigationController = navigationController
self.bugReportService = bugReportService
setupStateMachine()
@ -44,9 +42,7 @@ class UserSessionFlowCoordinator: Coordinator {
func start() {
stateMachine.processEvent(.start)
}
func stop() { }
// MARK: - Private
// swiftlint:disable:next cyclomatic_complexity
@ -60,23 +56,23 @@ class UserSessionFlowCoordinator: Coordinator {
case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)):
self.presentRoomWithIdentifier(roomId)
case(.roomScreen(let roomId), .dismissedRoomScreen, .homeScreen):
self.tearDownDismissedRoomScreen(roomId)
case(.roomScreen, .dismissedRoomScreen, .homeScreen):
break
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentSessionVerification()
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
self.tearDownDismissedSessionVerificationScreen()
break
case (.homeScreen, .showSettingsScreen, .settingsScreen):
self.presentSettingsScreen()
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
self.dismissSettingsScreen()
break
case (.homeScreen, .feedbackScreen, .feedbackScreen):
self.presentFeedbackScreen()
case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen):
self.dismissFeedbackScreen()
break
case (_, .resignActive, .suspended):
self.pause()
@ -106,14 +102,16 @@ class UserSessionFlowCoordinator: Coordinator {
private func presentHomeScreen() {
userSession.clientProxy.startSync()
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder())
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: bugReportService,
navigationController: navigationController)
let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .presentRoomScreen(let roomIdentifier):
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
@ -127,13 +125,8 @@ class UserSessionFlowCoordinator: Coordinator {
self.callback?(.signOut)
}
}
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator)
if bugReportService.crashedLastRun {
showCrashPopup()
}
navigationController.setRootCoordinator(coordinator)
}
// MARK: Rooms
@ -158,73 +151,52 @@ class UserSessionFlowCoordinator: Coordinator {
mediaProvider: userSession.mediaProvider,
roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter,
let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name,
roomAvatarUrl: roomProxy.avatarURL)
let coordinator = RoomScreenCoordinator(parameters: parameters)
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
navigationController.push(coordinator) { [weak self] in
guard let self else { return }
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
private func presentSettingsScreen() {
let navController = ElementNavigationController()
let newNavigationRouter = NavigationRouter(navigationController: navController)
let parameters = SettingsCoordinatorParameters(navigationRouter: newNavigationRouter,
let settingsNavigationController = NavigationController()
let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationController)
let parameters = SettingsCoordinatorParameters(navigationController: settingsNavigationController,
userNotificationController: userNotificationController,
userSession: userSession,
bugReportService: bugReportService)
let coordinator = SettingsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
let settingsCoordinator = SettingsCoordinator(parameters: parameters)
settingsCoordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
self.dismissSettingsScreen()
self.navigationController.dismissSheet()
case .logout:
self.dismissSettingsScreen()
self.navigationController.dismissSheet()
self.callback?(.signOut)
}
}
add(childCoordinator: coordinator)
coordinator.start()
navController.viewControllers = [coordinator.toPresentable()]
navigationRouter.present(navController, animated: true)
}
@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)
settingsNavigationController.setRootCoordinator(settingsCoordinator)
navigationController.presentSheet(userNotificationController) { [weak self] in
self?.stateMachine.processEvent(.dismissedSettingsScreen)
}
}
// MARK: Session verification
private func presentSessionVerification() {
guard let sessionVerificationController = userSession.sessionVerificationController else {
fatalError("The sessionVerificationController should aways be valid at this point")
@ -235,71 +207,37 @@ class UserSessionFlowCoordinator: Coordinator {
let coordinator = SessionVerificationCoordinator(parameters: parameters)
coordinator.callback = { [weak self] in
self?.navigationRouter.dismissModule()
self?.navigationController.dismissSheet()
}
navigationController.presentSheet(coordinator) { [weak self] in
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
private func showCrashPopup() {
let alert = UIAlertController(title: nil,
message: ElementL10n.sendBugReportAppCrashed,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: ElementL10n.no, style: .cancel))
alert.addAction(UIAlertAction(title: ElementL10n.yes, style: .default) { [weak self] _ in
self?.stateMachine.processEvent(.feedbackScreen)
})
navigationRouter.present(alert, animated: true)
}
private func presentFeedbackScreen(for image: UIImage? = nil) {
let feedbackNavigationController = NavigationController()
let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationController)
let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
screenshot: image)
userNotificationController: userNotificationController,
screenshot: image,
isModallyPresented: true)
let coordinator = BugReportCoordinator(parameters: parameters)
coordinator.completion = { [weak self] in
coordinator.completion = { [weak self] _ in
self?.navigationController.dismissSheet()
}
feedbackNavigationController.setRootCoordinator(coordinator)
navigationController.presentSheet(userNotificationController) { [weak self] in
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
private func pause() {

View File

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

View File

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

View File

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

View File

@ -25,42 +25,22 @@ enum TemplateCoordinatorAction {
case cancel
}
final class TemplateCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
final class TemplateCoordinator: CoordinatorProtocol {
private let parameters: TemplateCoordinatorParameters
private let templateHostingController: UIViewController
private var templateViewModel: TemplateViewModelProtocol
private var viewModel: TemplateViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((TemplateCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: TemplateCoordinatorParameters) {
self.parameters = parameters
let viewModel = TemplateViewModel(promptType: parameters.promptType)
let view = TemplateScreen(context: viewModel.context)
templateViewModel = viewModel
templateHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: templateHostingController)
viewModel = TemplateViewModel(promptType: parameters.promptType)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
templateViewModel.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("TemplateViewModel did complete with result: \(action).")
switch action {
@ -71,27 +51,8 @@ final class TemplateCoordinator: Coordinator, Presentable {
}
}
}
func toPresentable() -> UIViewController {
templateHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
func toPresentable() -> AnyView {
AnyView(TemplateScreen(context: viewModel.context))
}
}

View File

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

View File

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

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