mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
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:
parent
af85c770da
commit
2fd0491a18
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
@ -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
@ -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"
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
34
ElementX/Sources/Application/CoordinatorProtocol.swift
Normal file
34
ElementX/Sources/Application/CoordinatorProtocol.swift
Normal 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"))
|
||||
}
|
||||
}
|
186
ElementX/Sources/Application/NavigationController.swift
Normal file
186
ElementX/Sources/Application/NavigationController.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
@ -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)?)
|
||||
}
|
@ -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:
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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() { }
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
@ -21,39 +21,22 @@ struct AnalyticsPromptCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
}
|
||||
|
||||
final class AnalyticsPromptCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class AnalyticsPromptCoordinator: CoordinatorProtocol {
|
||||
private let parameters: AnalyticsPromptCoordinatorParameters
|
||||
private let analyticsPromptHostingController: UIViewController
|
||||
private var analyticsPromptViewModel: AnalyticsPromptViewModel
|
||||
|
||||
// MARK: Public
|
||||
private var viewModel: AnalyticsPromptViewModel
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor () -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AnalyticsPromptCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
|
||||
|
||||
let view = AnalyticsPrompt(context: viewModel.context)
|
||||
analyticsPromptViewModel = viewModel
|
||||
analyticsPromptHostingController = UIHostingController(rootView: view)
|
||||
viewModel = AnalyticsPromptViewModel(termsURL: BuildSettings.analyticsConfiguration.termsURL)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
|
||||
analyticsPromptViewModel.callback = { [weak self] result in
|
||||
viewModel.callback = { [weak self] result in
|
||||
MXLog.debug("AnalyticsPromptViewModel did complete with result: \(result).")
|
||||
|
||||
guard let self else { return }
|
||||
@ -69,7 +52,7 @@ final class AnalyticsPromptCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController { analyticsPromptHostingController }
|
||||
|
||||
func stop() { }
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(AnalyticsPrompt(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
protocol AuthenticationCoordinatorDelegate: AnyObject {
|
||||
@ -22,42 +22,31 @@ protocol AuthenticationCoordinatorDelegate: AnyObject {
|
||||
didLoginWithSession userSession: UserSessionProtocol)
|
||||
}
|
||||
|
||||
class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
private let authenticationService: AuthenticationServiceProxyProtocol
|
||||
private let navigationRouter: NavigationRouter
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
var childCoordinators: [Coordinator] = []
|
||||
private let navigationController: NavigationController
|
||||
|
||||
weak var delegate: AuthenticationCoordinatorDelegate?
|
||||
|
||||
init(authenticationService: AuthenticationServiceProxyProtocol,
|
||||
navigationRouter: NavigationRouter) {
|
||||
navigationController: NavigationController) {
|
||||
self.authenticationService = authenticationService
|
||||
self.navigationRouter = navigationRouter
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: navigationRouter.toPresentable())
|
||||
self.navigationController = navigationController
|
||||
}
|
||||
|
||||
func start() {
|
||||
showSplashScreen()
|
||||
showOnboarding()
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
navigationRouter.toPresentable()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Shows the splash screen as the root view in the navigation stack.
|
||||
private func showSplashScreen() {
|
||||
let coordinator = SplashScreenCoordinator()
|
||||
|
||||
private func showOnboarding() {
|
||||
let coordinator = OnboardingCoordinator()
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
@ -66,12 +55,7 @@ class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.setRootModule(coordinator) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
navigationController.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func startAuthentication() async {
|
||||
@ -89,7 +73,8 @@ class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
|
||||
private func showServerSelectionScreen() {
|
||||
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
hasModalPresentation: false)
|
||||
userNotificationController: ServiceLocator.shared.userNotificationController,
|
||||
isModallyPresented: false)
|
||||
let coordinator = ServerSelectionCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
@ -103,34 +88,24 @@ class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
navigationController.push(coordinator)
|
||||
}
|
||||
|
||||
private func showLoginScreen() {
|
||||
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
|
||||
navigationRouter: navigationRouter)
|
||||
navigationController: navigationController)
|
||||
let coordinator = LoginCoordinator(parameters: parameters)
|
||||
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
switch action {
|
||||
case .signedIn(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
navigationController.push(coordinator)
|
||||
}
|
||||
|
||||
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
|
||||
@ -141,22 +116,20 @@ class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
guard let self else { return }
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
navigationController.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
/// Show a blocking activity indicator.
|
||||
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
|
||||
|
||||
private func startLoading() {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: ElementL10n.loading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
activityIndicator = nil
|
||||
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ struct LoginCoordinatorParameters {
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProxyProtocol
|
||||
/// The navigation router used to present the server selection screen.
|
||||
let navigationRouter: NavigationRouterType
|
||||
let navigationController: NavigationController
|
||||
}
|
||||
|
||||
enum LoginCoordinatorAction {
|
||||
@ -29,14 +29,10 @@ enum LoginCoordinatorAction {
|
||||
case signedIn(UserSessionProtocol)
|
||||
}
|
||||
|
||||
final class LoginCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class LoginCoordinator: CoordinatorProtocol {
|
||||
private let parameters: LoginCoordinatorParameters
|
||||
private let loginHostingController: UIViewController
|
||||
private var loginViewModel: LoginViewModelProtocol
|
||||
private var viewModel: LoginViewModelProtocol
|
||||
private let hostingController: UIViewController
|
||||
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
|
||||
private let oidcUserAgent: OIDExternalUserAgentIOS?
|
||||
|
||||
@ -47,14 +43,8 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
private var navigationController: NavigationController { parameters.navigationController }
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
@ -62,22 +52,16 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
init(parameters: LoginCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
|
||||
loginViewModel = viewModel
|
||||
viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
|
||||
|
||||
let view = LoginScreen(context: viewModel.context)
|
||||
loginHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
|
||||
oidcUserAgent = OIDExternalUserAgentIOS(presenting: loginHostingController)
|
||||
hostingController = UIHostingController(rootView: LoginScreen(context: viewModel.context))
|
||||
oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
|
||||
loginViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("LoginViewModel did callback with result: \(action).")
|
||||
|
||||
@ -95,51 +79,52 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
loginHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(LoginScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show a blocking activity indicator whilst saving.
|
||||
static let loadingIndicatorIdentifier = "LoginCoordinatorLoading"
|
||||
|
||||
private func startLoading(isInteractionBlocking: Bool) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: isInteractionBlocking))
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: ElementL10n.loading,
|
||||
persistent: true))
|
||||
|
||||
if !isInteractionBlocking {
|
||||
loginViewModel.update(isLoading: true)
|
||||
viewModel.update(isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation was successful.
|
||||
private func indicateSuccess() {
|
||||
activityIndicator = indicatorPresenter.present(.success(label: ElementL10n.dialogTitleSuccess))
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation failed.
|
||||
private func indicateFailure() {
|
||||
activityIndicator = indicatorPresenter.present(.error(label: ElementL10n.dialogTitleError))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loginViewModel.update(isLoading: false)
|
||||
activityIndicator = nil
|
||||
viewModel.update(isLoading: false)
|
||||
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
private func indicateSuccess() {
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.dialogTitleSuccess))
|
||||
}
|
||||
|
||||
private func indicateFailure() {
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.dialogTitleError))
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidCredentials:
|
||||
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
|
||||
viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
|
||||
case .accountDeactivated:
|
||||
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
|
||||
viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
|
||||
default:
|
||||
loginViewModel.displayError(.alert(ElementL10n.unknownError))
|
||||
viewModel.displayError(.alert(ElementL10n.unknownError))
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,44 +189,43 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
/// Updates the view model with a different homeserver.
|
||||
private func updateViewModel() {
|
||||
loginViewModel.update(homeserver: authenticationService.homeserver)
|
||||
viewModel.update(homeserver: authenticationService.homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
private func presentServerSelectionScreen() {
|
||||
MXLog.debug("PresentServerSelectionScreen")
|
||||
let serverSelectionNavigationController = NavigationController()
|
||||
|
||||
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationController)
|
||||
|
||||
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
hasModalPresentation: true)
|
||||
userNotificationController: userNotificationController,
|
||||
isModallyPresented: true)
|
||||
|
||||
let coordinator = ServerSelectionCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] action in
|
||||
guard let self, let coordinator = coordinator else { return }
|
||||
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
serverSelectionNavigationController.setRootCoordinator(coordinator)
|
||||
|
||||
let modalRouter = NavigationRouter(navigationController: ElementNavigationController())
|
||||
modalRouter.setRootModule(coordinator)
|
||||
|
||||
navigationRouter.present(modalRouter, animated: true)
|
||||
navigationController.presentSheet(userNotificationController)
|
||||
}
|
||||
|
||||
/// Handles the result from the server selection modal, dismissing it after updating the view.
|
||||
private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator,
|
||||
didCompleteWith action: ServerSelectionCoordinatorAction) {
|
||||
navigationRouter.dismissModule(animated: true) { [weak self] in
|
||||
if action == .updated {
|
||||
self?.updateViewModel()
|
||||
}
|
||||
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
if action == .updated {
|
||||
updateViewModel()
|
||||
}
|
||||
|
||||
navigationController.dismissSheet()
|
||||
}
|
||||
|
||||
/// Shows the forgot password screen.
|
||||
private func showForgotPasswordScreen() {
|
||||
loginViewModel.displayError(.alert("Not implemented."))
|
||||
viewModel.displayError(.alert("Not implemented."))
|
||||
}
|
||||
}
|
||||
|
@ -27,18 +27,18 @@ enum MockServerSelectionScreenState: CaseIterable {
|
||||
switch self {
|
||||
case .matrix:
|
||||
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
|
||||
hasModalPresentation: true)
|
||||
isModallyPresented: true)
|
||||
case .emptyAddress:
|
||||
return ServerSelectionViewModel(homeserverAddress: "",
|
||||
hasModalPresentation: true)
|
||||
isModallyPresented: true)
|
||||
case .invalidAddress:
|
||||
let viewModel = ServerSelectionViewModel(homeserverAddress: "thisisbad",
|
||||
hasModalPresentation: true)
|
||||
isModallyPresented: true)
|
||||
viewModel.displayError(.footerMessage(ElementL10n.unknownError))
|
||||
return viewModel
|
||||
case .nonModal:
|
||||
return ServerSelectionViewModel(homeserverAddress: "https://matrix.org",
|
||||
hasModalPresentation: false)
|
||||
isModallyPresented: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,8 +19,9 @@ import SwiftUI
|
||||
struct ServerSelectionCoordinatorParameters {
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProxyProtocol
|
||||
let userNotificationController: UserNotificationControllerProtocol
|
||||
/// Whether the screen is presented modally or within a navigation stack.
|
||||
let hasModalPresentation: Bool
|
||||
let isModallyPresented: Bool
|
||||
}
|
||||
|
||||
enum ServerSelectionCoordinatorAction {
|
||||
@ -28,45 +29,25 @@ enum ServerSelectionCoordinatorAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class ServerSelectionCoordinator: CoordinatorProtocol {
|
||||
private let parameters: ServerSelectionCoordinatorParameters
|
||||
private let serverSelectionHostingController: UIViewController
|
||||
private var serverSelectionViewModel: ServerSelectionViewModelProtocol
|
||||
|
||||
private let userNotificationController: UserNotificationControllerProtocol
|
||||
private var viewModel: ServerSelectionViewModelProtocol
|
||||
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (ServerSelectionCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: ServerSelectionCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
|
||||
hasModalPresentation: parameters.hasModalPresentation)
|
||||
let view = ServerSelectionScreen(context: viewModel.context)
|
||||
serverSelectionViewModel = viewModel
|
||||
serverSelectionHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: serverSelectionHostingController)
|
||||
viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
|
||||
isModallyPresented: parameters.isModallyPresented)
|
||||
userNotificationController = parameters.userNotificationController
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
|
||||
serverSelectionViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("ServerSelectionViewModel did callback with action: \(action).")
|
||||
|
||||
@ -79,27 +60,24 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
serverSelectionHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(ServerSelectionScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func startLoading(label: String = ElementL10n.loading) {
|
||||
userNotificationController.submitNotification(UserNotification(type: .modal,
|
||||
title: label,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
userNotificationController.retractAllNotifications()
|
||||
}
|
||||
|
||||
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
|
||||
@ -122,9 +100,9 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidServer, .invalidHomeserverAddress:
|
||||
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
|
||||
viewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
|
||||
default:
|
||||
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.unknownError))
|
||||
viewModel.displayError(.footerMessage(ElementL10n.unknownError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ struct ServerSelectionViewState: BindableState {
|
||||
/// An error message to be shown in the text field footer.
|
||||
var footerErrorMessage: String?
|
||||
/// Whether the screen is presented modally or within a navigation stack.
|
||||
var hasModalPresentation: Bool
|
||||
var isModallyPresented: Bool
|
||||
|
||||
/// The message to show in the text field footer.
|
||||
var footerMessage: String {
|
||||
@ -42,7 +42,7 @@ struct ServerSelectionViewState: BindableState {
|
||||
|
||||
/// The title shown on the confirm button.
|
||||
var buttonTitle: String {
|
||||
hasModalPresentation ? ElementL10n.actionConfirm : ElementL10n.actionNext
|
||||
isModallyPresented ? ElementL10n.actionConfirm : ElementL10n.actionNext
|
||||
}
|
||||
|
||||
/// The text field is showing an error.
|
||||
|
@ -28,13 +28,13 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
|
||||
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserverAddress: String, hasModalPresentation: Bool) {
|
||||
|
||||
init(homeserverAddress: String, isModallyPresented: Bool) {
|
||||
let bindings = ServerSelectionBindings(homeserverAddress: homeserverAddress)
|
||||
super.init(initialViewState: ServerSelectionViewState(bindings: bindings,
|
||||
hasModalPresentation: hasModalPresentation))
|
||||
isModallyPresented: isModallyPresented))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: ServerSelectionViewAction) async {
|
||||
|
@ -44,6 +44,7 @@ struct ServerSelectionScreen: View {
|
||||
.background(Color.element.background, ignoresSafeAreaEdges: .all)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
|
||||
/// The title, message and icon at the top of the screen.
|
||||
@ -91,7 +92,7 @@ struct ServerSelectionScreen: View {
|
||||
@ToolbarContentBuilder
|
||||
var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
if context.viewState.hasModalPresentation {
|
||||
if context.viewState.isModallyPresented {
|
||||
Button { context.send(viewAction: .dismiss) } label: {
|
||||
Text(ElementL10n.actionCancel)
|
||||
}
|
||||
|
@ -40,55 +40,35 @@ enum SoftLogoutCoordinatorResult: CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
final class SoftLogoutCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class SoftLogoutCoordinator: CoordinatorProtocol {
|
||||
private let parameters: SoftLogoutCoordinatorParameters
|
||||
private let softLogoutHostingController: UIViewController
|
||||
private var softLogoutViewModel: SoftLogoutViewModelProtocol
|
||||
private var viewModel: SoftLogoutViewModelProtocol
|
||||
private let hostingController: UIViewController
|
||||
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
|
||||
private let oidcUserAgent: OIDExternalUserAgentIOS?
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var successIndicator: UserIndicator?
|
||||
|
||||
/// The wizard used to handle the registration flow.
|
||||
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@MainActor init(parameters: SoftLogoutCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let homeserver = parameters.authenticationService.homeserver
|
||||
|
||||
let viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
|
||||
homeserver: homeserver,
|
||||
keyBackupNeeded: parameters.keyBackupNeeded)
|
||||
softLogoutViewModel = viewModel
|
||||
|
||||
let view = SoftLogoutScreen(context: viewModel.context)
|
||||
softLogoutHostingController = UIHostingController(rootView: view)
|
||||
viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
|
||||
homeserver: homeserver,
|
||||
keyBackupNeeded: parameters.keyBackupNeeded)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: softLogoutHostingController)
|
||||
oidcUserAgent = OIDExternalUserAgentIOS(presenting: softLogoutHostingController)
|
||||
hostingController = UIHostingController(rootView: SoftLogoutScreen(context: viewModel.context))
|
||||
oidcUserAgent = OIDExternalUserAgentIOS(presenting: hostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[SoftLogoutCoordinator] did start.")
|
||||
|
||||
softLogoutViewModel.callback = { [weak self] result in
|
||||
viewModel.callback = { [weak self] result in
|
||||
guard let self else { return }
|
||||
MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).")
|
||||
|
||||
@ -105,31 +85,36 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
softLogoutHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SoftLogoutScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
static let loadingIndicatorIdentifier = "SoftLogoutLoading"
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
@MainActor private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: ElementL10n.loading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
@MainActor private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
/// Shows the forgot password screen.
|
||||
@MainActor private func showForgotPasswordScreen() {
|
||||
MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen")
|
||||
|
||||
softLogoutViewModel.displayError(.alert("Not implemented."))
|
||||
viewModel.displayError(.alert("Not implemented."))
|
||||
}
|
||||
|
||||
/// Login with the supplied username and password.
|
||||
@ -177,11 +162,11 @@ final class SoftLogoutCoordinator: Coordinator, Presentable {
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidCredentials:
|
||||
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
|
||||
viewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
|
||||
case .accountDeactivated:
|
||||
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
|
||||
viewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
|
||||
default:
|
||||
softLogoutViewModel.displayError(.alert(ElementL10n.unknownError))
|
||||
viewModel.displayError(.alert(ElementL10n.unknownError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,90 +16,77 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BugReportCoordinatorParameters {
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
let screenshot: UIImage?
|
||||
enum BugReportCoordinatorResult {
|
||||
case cancel
|
||||
case finish
|
||||
}
|
||||
|
||||
final class BugReportCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: BugReportCoordinatorParameters
|
||||
private let bugReportHostingController: UIViewController
|
||||
private var bugReportViewModel: BugReportViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var statusIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
struct BugReportCoordinatorParameters {
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
let userNotificationController: UserNotificationControllerProtocol
|
||||
let screenshot: UIImage?
|
||||
let isModallyPresented: Bool
|
||||
}
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: (() -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
final class BugReportCoordinator: CoordinatorProtocol {
|
||||
private let parameters: BugReportCoordinatorParameters
|
||||
private var viewModel: BugReportViewModelProtocol
|
||||
|
||||
var completion: ((BugReportCoordinatorResult) -> Void)?
|
||||
|
||||
init(parameters: BugReportCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
|
||||
screenshot: parameters.screenshot)
|
||||
let view = BugReportScreen(context: viewModel.context)
|
||||
bugReportViewModel = viewModel
|
||||
bugReportHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: bugReportHostingController)
|
||||
viewModel = BugReportViewModel(bugReportService: parameters.bugReportService,
|
||||
screenshot: parameters.screenshot,
|
||||
isModallyPresented: parameters.isModallyPresented)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
bugReportViewModel.callback = { [weak self] result in
|
||||
viewModel.callback = { [weak self] result in
|
||||
guard let self else { return }
|
||||
MXLog.debug("BugReportViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .cancel:
|
||||
self.completion?(.cancel)
|
||||
case .submitStarted:
|
||||
self.startLoading()
|
||||
case .submitFinished:
|
||||
self.stopLoading()
|
||||
self.completion?()
|
||||
self.completion?(.finish)
|
||||
case .submitFailed(let error):
|
||||
self.stopLoading()
|
||||
self.showError(label: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
bugReportHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(BugReportScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label,
|
||||
isInteractionBlocking: isInteractionBlocking))
|
||||
static let loadingIndicatorIdentifier = "BugReportLoading"
|
||||
|
||||
private func startLoading(label: String = ElementL10n.loading) {
|
||||
parameters.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: label,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
parameters.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
/// Show error indicator
|
||||
|
||||
private func showError(label: String) {
|
||||
statusIndicator = indicatorPresenter.present(.error(label: label))
|
||||
parameters.userNotificationController.submitNotification(UserNotification(title: label))
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ import UIKit
|
||||
// MARK: View model
|
||||
|
||||
enum BugReportViewModelAction {
|
||||
case cancel
|
||||
case submitStarted
|
||||
case submitFinished
|
||||
case submitFailed(error: Error)
|
||||
@ -32,6 +33,7 @@ enum BugReportViewModelAction {
|
||||
struct BugReportViewState: BindableState {
|
||||
var screenshot: UIImage?
|
||||
var bindings: BugReportViewStateBindings
|
||||
let isModallyPresented: Bool
|
||||
}
|
||||
|
||||
struct BugReportViewStateBindings {
|
||||
@ -40,6 +42,7 @@ struct BugReportViewStateBindings {
|
||||
}
|
||||
|
||||
enum BugReportViewAction {
|
||||
case cancel
|
||||
case submit
|
||||
case toggleSendLogs
|
||||
case removeScreenshot
|
||||
|
@ -16,18 +16,41 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState,
|
||||
BugReportViewAction>
|
||||
@available(iOS 14, *)
|
||||
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState, BugReportViewAction>
|
||||
|
||||
class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
|
||||
var callback: ((BugReportViewModelAction) -> Void)?
|
||||
|
||||
init(bugReportService: BugReportServiceProtocol,
|
||||
screenshot: UIImage?,
|
||||
isModallyPresented: Bool) {
|
||||
self.bugReportService = bugReportService
|
||||
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
|
||||
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
|
||||
bindings: bindings,
|
||||
isModallyPresented: isModallyPresented))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: BugReportViewAction) async {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
case .submit:
|
||||
await submitBugReport()
|
||||
case .toggleSendLogs:
|
||||
context.sendingLogsEnabled.toggle()
|
||||
case .removeScreenshot:
|
||||
state.screenshot = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
func submitBugReport() async {
|
||||
private func submitBugReport() async {
|
||||
callback?(.submitStarted)
|
||||
do {
|
||||
var files: [URL] = []
|
||||
@ -54,31 +77,4 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
|
||||
callback?(.submitFailed(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((BugReportViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(bugReportService: BugReportServiceProtocol,
|
||||
screenshot: UIImage?) {
|
||||
self.bugReportService = bugReportService
|
||||
let bindings = BugReportViewStateBindings(reportText: "", sendingLogsEnabled: true)
|
||||
super.init(initialViewState: BugReportViewState(screenshot: screenshot,
|
||||
bindings: bindings))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: BugReportViewAction) async {
|
||||
switch viewAction {
|
||||
case .submit:
|
||||
await submitBugReport()
|
||||
case .toggleSendLogs:
|
||||
context.sendingLogsEnabled.toggle()
|
||||
case .removeScreenshot:
|
||||
state.screenshot = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,5 @@ import Foundation
|
||||
@MainActor
|
||||
protocol BugReportViewModelProtocol {
|
||||
var callback: ((BugReportViewModelAction) -> Void)? { get set }
|
||||
@available(iOS 14, *)
|
||||
var context: BugReportViewModelType.Context { get }
|
||||
}
|
||||
|
@ -48,6 +48,16 @@ struct BugReportScreen: View {
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
|
||||
}
|
||||
.navigationTitle(ElementL10n.titleActivityBugReport)
|
||||
.toolbar {
|
||||
if context.viewState.isModallyPresented {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(ElementL10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,7 +146,7 @@ struct BugReport_Previews: PreviewProvider {
|
||||
|
||||
@ViewBuilder
|
||||
static var body: some View {
|
||||
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image)
|
||||
let viewModel = BugReportViewModel(bugReportService: MockBugReportService(), screenshot: Asset.Images.appLogo.image, isModallyPresented: false)
|
||||
BugReportScreen(context: viewModel.context)
|
||||
.previewInterfaceOrientation(.portrait)
|
||||
}
|
||||
|
@ -25,42 +25,22 @@ enum FilePreviewCoordinatorAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class FilePreviewCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class FilePreviewCoordinator: CoordinatorProtocol {
|
||||
private let parameters: FilePreviewCoordinatorParameters
|
||||
private let filePreviewHostingController: UIViewController
|
||||
private var filePreviewViewModel: FilePreviewViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
private var viewModel: FilePreviewViewModelProtocol
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((FilePreviewCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: FilePreviewCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
|
||||
let view = FilePreviewScreen(context: viewModel.context)
|
||||
filePreviewViewModel = viewModel
|
||||
filePreviewHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: filePreviewHostingController)
|
||||
viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
filePreviewViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("FilePreviewViewModel did complete with result: \(action).")
|
||||
switch action {
|
||||
@ -70,26 +50,7 @@ final class FilePreviewCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
filePreviewHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
activityIndicator = nil
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(FilePreviewScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ import SwiftUI
|
||||
struct HomeScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
let navigationController: NavigationController
|
||||
}
|
||||
|
||||
enum HomeScreenCoordinatorAction {
|
||||
@ -30,34 +32,20 @@ enum HomeScreenCoordinatorAction {
|
||||
case signOut
|
||||
}
|
||||
|
||||
final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class HomeScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: HomeScreenCoordinatorParameters
|
||||
private let hostingController: UIViewController
|
||||
private var viewModel: HomeScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((HomeScreenCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: HomeScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
|
||||
attributedStringBuilder: parameters.attributedStringBuilder)
|
||||
|
||||
let view = HomeScreen(context: viewModel.context)
|
||||
hostingController = UIHostingController(rootView: view)
|
||||
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@ -74,13 +62,21 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() { }
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
hostingController
|
||||
func start() {
|
||||
if parameters.bugReportService.crashedLastRun {
|
||||
viewModel.presentAlert(
|
||||
AlertInfo(id: UUID(),
|
||||
title: ElementL10n.sendBugReportAppCrashed,
|
||||
primaryButton: .init(title: ElementL10n.no, action: nil),
|
||||
secondaryButton: .init(title: ElementL10n.yes) { [weak self] in
|
||||
self?.callback?(.presentFeedbackScreen)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(HomeScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ -98,11 +94,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
private func presentInviteFriends() {
|
||||
guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: parameters.userSession.userID).absoluteString else {
|
||||
return
|
||||
}
|
||||
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
|
||||
let vc = UIActivityViewController(activityItems: [shareText], applicationActivities: nil)
|
||||
hostingController.present(vc, animated: true)
|
||||
parameters.navigationController.presentSheet(InviteFriendsCoordinator(userId: parameters.userSession.userID))
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ struct HomeScreenViewState: BindableState {
|
||||
|
||||
var rooms: [HomeScreenRoom] = []
|
||||
|
||||
var roomListMode: HomeScreenRoomListMode = .rooms
|
||||
var roomListMode: HomeScreenRoomListMode = .skeletons
|
||||
|
||||
var visibleRooms: [HomeScreenRoom] {
|
||||
if roomListMode == .skeletons {
|
||||
@ -77,6 +77,8 @@ struct HomeScreenViewState: BindableState {
|
||||
|
||||
struct HomeScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
}
|
||||
|
||||
struct HomeScreenRoom: Identifiable, Equatable {
|
||||
@ -92,15 +94,12 @@ struct HomeScreenRoom: Identifiable, Equatable {
|
||||
|
||||
var avatar: UIImage?
|
||||
|
||||
var isPlaceholder = false
|
||||
|
||||
static func placeholder(id: String) -> HomeScreenRoom {
|
||||
HomeScreenRoom(id: id,
|
||||
name: "Placeholder room name",
|
||||
hasUnreads: false,
|
||||
timestamp: "Now",
|
||||
lastMessage: AttributedString("Last message"),
|
||||
avatar: UIImage(systemName: "photo"),
|
||||
isPlaceholder: true)
|
||||
avatar: UIImage(systemName: "photo"))
|
||||
}
|
||||
}
|
||||
|
@ -111,6 +111,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
}
|
||||
}
|
||||
|
||||
func presentAlert(_ alertInfo: AlertInfo<UUID>) {
|
||||
state.bindings.alertInfo = alertInfo
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadDataForRoomIdentifier(_ identifier: String) {
|
||||
|
@ -22,4 +22,6 @@ protocol HomeScreenViewModelProtocol {
|
||||
var callback: ((HomeScreenViewModelAction) -> Void)? { get set }
|
||||
|
||||
var context: HomeScreenViewModelType.Context { get }
|
||||
|
||||
func presentAlert(_ alert: AlertInfo<UUID>)
|
||||
}
|
||||
|
@ -28,23 +28,30 @@ struct HomeScreen: View {
|
||||
sessionVerificationBanner
|
||||
}
|
||||
|
||||
LazyVStack {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
if room.isPlaceholder {
|
||||
if context.viewState.roomListMode == .skeletons {
|
||||
LazyVStack {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
.redacted(reason: .placeholder)
|
||||
.disabled(true)
|
||||
} else {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
LazyVStack {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.searchable(text: $context.searchQuery)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.searchable(text: $context.searchQuery)
|
||||
}
|
||||
.disabled(context.viewState.roomListMode == .skeletons)
|
||||
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
|
||||
.animation(.elementDefault, value: context.viewState.roomListMode)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
.navigationTitle(ElementL10n.allChats)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
|
@ -24,42 +24,23 @@ enum MediaPlayerCoordinatorAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class MediaPlayerCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class MediaPlayerCoordinator: CoordinatorProtocol {
|
||||
private let parameters: MediaPlayerCoordinatorParameters
|
||||
private let mediaPlayerHostingController: UIViewController
|
||||
private var mediaPlayerViewModel: MediaPlayerViewModelProtocol
|
||||
private var viewModel: MediaPlayerViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((MediaPlayerCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: MediaPlayerCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
|
||||
let view = MediaPlayerScreen(context: viewModel.context)
|
||||
mediaPlayerViewModel = viewModel
|
||||
mediaPlayerHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mediaPlayerHostingController)
|
||||
viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
mediaPlayerViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("MediaPlayerViewModel did complete with result: \(action).")
|
||||
switch action {
|
||||
@ -69,26 +50,7 @@ final class MediaPlayerCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
mediaPlayerHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
activityIndicator = nil
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(MediaPlayerScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
final class OnboardingCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: OnboardingViewModelProtocol
|
||||
|
||||
var callback: ((OnboardingCoordinatorAction) -> Void)?
|
||||
|
||||
init() {
|
||||
viewModel = OnboardingViewModel()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
viewModel.callback = { [weak self] action in
|
||||
MXLog.debug("OnboardingViewModel did complete with result: \(action).")
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .login:
|
||||
self.callback?(.login)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(OnboardingScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
@ -18,12 +18,12 @@ import SwiftUI
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
enum SplashScreenCoordinatorAction {
|
||||
enum OnboardingCoordinatorAction {
|
||||
case login
|
||||
}
|
||||
|
||||
/// The content displayed in a single splash screen page.
|
||||
struct SplashScreenPageContent {
|
||||
/// The content displayed in a single screen page.
|
||||
struct OnboardingPageContent {
|
||||
let title: AttributedString
|
||||
let message: String
|
||||
let image: ImageAsset
|
||||
@ -31,13 +31,13 @@ struct SplashScreenPageContent {
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum SplashScreenViewModelAction {
|
||||
enum OnboardingViewModelAction {
|
||||
case login
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct SplashScreenViewState: BindableState {
|
||||
struct OnboardingViewState: BindableState {
|
||||
/// The colours of the background gradient shown behind the 4 pages.
|
||||
private let gradientColors = [
|
||||
Color(red: 0.95, green: 0.98, blue: 0.96),
|
||||
@ -48,8 +48,8 @@ struct SplashScreenViewState: BindableState {
|
||||
]
|
||||
|
||||
/// An array containing all content of the carousel pages
|
||||
let content: [SplashScreenPageContent]
|
||||
var bindings: SplashScreenBindings
|
||||
let content: [OnboardingPageContent]
|
||||
var bindings: OnboardingBindings
|
||||
|
||||
/// The background gradient for all 4 pages and the hidden page at the start of the carousel.
|
||||
var backgroundGradient: Gradient {
|
||||
@ -69,27 +69,27 @@ struct SplashScreenViewState: BindableState {
|
||||
let page4Title = locale.identifier.hasPrefix("en") ? "Cut the slack from teams." : ElementL10n.ftueAuthCarouselWorkplaceTitle
|
||||
|
||||
content = [
|
||||
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselSecureBody,
|
||||
image: Asset.Images.splashScreenPage1),
|
||||
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselControlBody,
|
||||
image: Asset.Images.splashScreenPage2),
|
||||
SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselEncryptedBody,
|
||||
image: Asset.Images.splashScreenPage3),
|
||||
SplashScreenPageContent(title: page4Title.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
|
||||
image: Asset.Images.splashScreenPage4)
|
||||
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselSecureBody,
|
||||
image: Asset.Images.onboardingScreenPage1),
|
||||
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselControlBody,
|
||||
image: Asset.Images.onboardingScreenPage2),
|
||||
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselEncryptedBody,
|
||||
image: Asset.Images.onboardingScreenPage3),
|
||||
OnboardingPageContent(title: page4Title.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
|
||||
image: Asset.Images.onboardingScreenPage4)
|
||||
]
|
||||
bindings = SplashScreenBindings()
|
||||
bindings = OnboardingBindings()
|
||||
}
|
||||
}
|
||||
|
||||
struct SplashScreenBindings {
|
||||
struct OnboardingBindings {
|
||||
var pageIndex = 0
|
||||
}
|
||||
|
||||
enum SplashScreenViewAction {
|
||||
enum OnboardingViewAction {
|
||||
case login
|
||||
}
|
@ -17,26 +17,26 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias SplashScreenViewModelType = StateStoreViewModel<SplashScreenViewState, SplashScreenViewAction>
|
||||
typealias OnboardingViewModelType = StateStoreViewModel<OnboardingViewState, OnboardingViewAction>
|
||||
|
||||
class SplashScreenViewModel: SplashScreenViewModelType, SplashScreenViewModelProtocol {
|
||||
class OnboardingViewModel: OnboardingViewModelType, OnboardingViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((SplashScreenViewModelAction) -> Void)?
|
||||
var callback: ((OnboardingViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
super.init(initialViewState: SplashScreenViewState())
|
||||
super.init(initialViewState: OnboardingViewState())
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: SplashScreenViewAction) async {
|
||||
override func process(viewAction: OnboardingViewAction) async {
|
||||
switch viewAction {
|
||||
case .login:
|
||||
callback?(.login)
|
@ -17,7 +17,7 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol SplashScreenViewModelProtocol {
|
||||
var callback: ((SplashScreenViewModelAction) -> Void)? { get set }
|
||||
var context: SplashScreenViewModelType.Context { get }
|
||||
protocol OnboardingViewModelProtocol {
|
||||
var callback: ((OnboardingViewModelAction) -> Void)? { get set }
|
||||
var context: OnboardingViewModelType.Context { get }
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplashScreenPageIndicator: View {
|
||||
struct OnboardingPageIndicator: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
@ -16,13 +16,13 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplashScreenPageView: View {
|
||||
struct OnboardingPageView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
||||
|
||||
/// The content that this page should display.
|
||||
let content: SplashScreenPageContent
|
||||
let content: OnboardingPageContent
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
@ -54,11 +54,11 @@ struct SplashScreenPageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SplashScreenPage_Previews: PreviewProvider {
|
||||
static let content = SplashScreenViewState().content
|
||||
struct OnboardingPage_Previews: PreviewProvider {
|
||||
static let content = OnboardingViewState().content
|
||||
static var previews: some View {
|
||||
ForEach(0..<content.count, id: \.self) { index in
|
||||
SplashScreenPageView(content: content[index])
|
||||
OnboardingPageView(content: content[index])
|
||||
}
|
||||
}
|
||||
}
|
@ -17,8 +17,8 @@
|
||||
import DesignKit
|
||||
import SwiftUI
|
||||
|
||||
/// The splash screen shown at the beginning of the onboarding flow.
|
||||
struct SplashScreen: View {
|
||||
/// The screen shown at the beginning of the onboarding flow.
|
||||
struct OnboardingScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
@ -36,7 +36,7 @@ struct SplashScreen: View {
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: SplashScreenViewModel.Context
|
||||
@ObservedObject var context: OnboardingViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
@ -47,12 +47,12 @@ struct SplashScreen: View {
|
||||
// The main content of the carousel
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Add a hidden page at the start of the carousel duplicating the content of the last page
|
||||
SplashScreenPageView(content: context.viewState.content[pageCount - 1])
|
||||
OnboardingPageView(content: context.viewState.content[pageCount - 1])
|
||||
.frame(width: geometry.size.width)
|
||||
.accessibilityIdentifier("hiddenPage")
|
||||
|
||||
ForEach(0..<pageCount, id: \.self) { index in
|
||||
SplashScreenPageView(content: context.viewState.content[index])
|
||||
OnboardingPageView(content: context.viewState.content[index])
|
||||
.frame(width: geometry.size.width)
|
||||
}
|
||||
}
|
||||
@ -60,7 +60,7 @@ struct SplashScreen: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
SplashScreenPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
|
||||
OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
|
||||
.frame(width: geometry.size.width)
|
||||
.padding(.bottom)
|
||||
|
||||
@ -213,11 +213,11 @@ struct SplashScreen: View {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct SplashScreen_Previews: PreviewProvider {
|
||||
static let viewModel = SplashScreenViewModel()
|
||||
struct OnboardingScreen_Previews: PreviewProvider {
|
||||
static let viewModel = OnboardingViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
SplashScreen(context: viewModel.context)
|
||||
OnboardingScreen(context: viewModel.context)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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>) { }
|
||||
}
|
@ -17,49 +17,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoomScreenCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let navigationController: NavigationController
|
||||
let timelineController: RoomTimelineControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let roomName: String?
|
||||
let roomAvatarUrl: String?
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: RoomScreenCoordinatorParameters
|
||||
private let roomScreenHostingController: UIViewController
|
||||
private var roomScreenViewModel: RoomScreenViewModelProtocol
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
// MARK: - Setup
|
||||
private var viewModel: RoomScreenViewModelProtocol
|
||||
private var navigationController: NavigationController { parameters.navigationController }
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
||||
timelineViewFactory: RoomTimelineViewFactory(),
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
roomName: parameters.roomName,
|
||||
roomAvatarUrl: parameters.roomAvatarUrl)
|
||||
|
||||
let view = RoomScreen(context: viewModel.context)
|
||||
roomScreenViewModel = viewModel
|
||||
roomScreenHostingController = UIHostingController(rootView: view)
|
||||
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
||||
timelineViewFactory: RoomTimelineViewFactory(),
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
roomName: parameters.roomName,
|
||||
roomAvatarUrl: parameters.roomAvatarUrl)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
roomScreenViewModel.callback = { [weak self] result in
|
||||
viewModel.callback = { [weak self] result in
|
||||
guard let self else { return }
|
||||
MXLog.debug("RoomScreenViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
@ -71,12 +55,12 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
roomScreenHostingController
|
||||
}
|
||||
|
||||
func stop() {
|
||||
roomScreenViewModel.stop()
|
||||
viewModel.stop()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(RoomScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@ -84,32 +68,20 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
|
||||
private func displayVideo(for videoURL: URL) {
|
||||
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
|
||||
let coordinator = VideoPlayerCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self, let coordinator = coordinator else { return }
|
||||
self.navigationRouter.popModule(animated: true)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationController.pop()
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
navigationController.push(coordinator)
|
||||
}
|
||||
|
||||
private func displayFile(for fileURL: URL, with title: String?) {
|
||||
let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title)
|
||||
let coordinator = FilePreviewCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self, let coordinator = coordinator else { return }
|
||||
self.navigationRouter.popModule(animated: true)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationController.pop()
|
||||
}
|
||||
|
||||
navigationController.push(coordinator)
|
||||
}
|
||||
}
|
||||
|
@ -20,37 +20,22 @@ struct SessionVerificationCoordinatorParameters {
|
||||
let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol
|
||||
}
|
||||
|
||||
final class SessionVerificationCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class SessionVerificationCoordinator: CoordinatorProtocol {
|
||||
private let parameters: SessionVerificationCoordinatorParameters
|
||||
private let sessionVerificationHostingController: UIViewController
|
||||
private var sessionVerificationViewModel: SessionVerificationViewModelProtocol
|
||||
private var viewModel: SessionVerificationViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (() -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: SessionVerificationCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
|
||||
let view = SessionVerificationScreen(context: viewModel.context)
|
||||
sessionVerificationViewModel = viewModel
|
||||
sessionVerificationHostingController = UIHostingController(rootView: view)
|
||||
viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
sessionVerificationViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
@ -60,9 +45,7 @@ final class SessionVerificationCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
sessionVerificationHostingController
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SessionVerificationScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
}
|
||||
|
@ -17,7 +17,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let navigationController: NavigationController
|
||||
let userNotificationController: UserNotificationControllerProtocol
|
||||
let userSession: UserSessionProtocol
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
}
|
||||
@ -27,25 +28,10 @@ enum SettingsCoordinatorAction {
|
||||
case logout
|
||||
}
|
||||
|
||||
final class SettingsCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class SettingsCoordinator: CoordinatorProtocol {
|
||||
private let parameters: SettingsCoordinatorParameters
|
||||
private let settingsHostingController: UIViewController
|
||||
private var settingsViewModel: SettingsViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var statusIndicator: UserIndicator?
|
||||
private var viewModel: SettingsViewModelProtocol
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((SettingsCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
@ -53,14 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
init(parameters: SettingsCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = SettingsViewModel(withUserSession: parameters.userSession)
|
||||
let view = SettingsScreen(context: viewModel.context)
|
||||
settingsViewModel = viewModel
|
||||
settingsHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: settingsHostingController)
|
||||
|
||||
settingsViewModel.callback = { [weak self] result in
|
||||
viewModel = SettingsViewModel(withUserSession: parameters.userSession)
|
||||
viewModel.callback = { [weak self] result in
|
||||
guard let self else { return }
|
||||
MXLog.debug("SettingsViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
@ -80,16 +60,10 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
// no-op
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SettingsScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
settingsHostingController
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleAnalytics() {
|
||||
@ -102,39 +76,25 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
|
||||
private func presentBugReportScreen() {
|
||||
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
|
||||
screenshot: nil)
|
||||
userNotificationController: parameters.userNotificationController,
|
||||
screenshot: nil,
|
||||
isModallyPresented: false)
|
||||
let coordinator = BugReportCoordinator(parameters: params)
|
||||
coordinator.completion = { [weak self, weak coordinator] in
|
||||
guard let self, let coordinator = coordinator else { return }
|
||||
self.parameters.navigationRouter.popModule(animated: true)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.showSuccess(label: ElementL10n.done)
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
self.remove(childCoordinator: coordinator)
|
||||
coordinator.completion = { [weak self] result in
|
||||
switch result {
|
||||
case .finish:
|
||||
self?.showSuccess(label: ElementL10n.done)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self?.parameters.navigationController.pop()
|
||||
}
|
||||
|
||||
parameters.navigationController.push(coordinator)
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
|
||||
/// Show success indicator
|
||||
private func showSuccess(label: String) {
|
||||
statusIndicator = indicatorPresenter.present(.success(label: label))
|
||||
parameters.userNotificationController.submitNotification(UserNotification(title: label))
|
||||
}
|
||||
}
|
||||
|
@ -16,10 +16,8 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState,
|
||||
SettingsViewAction>
|
||||
@available(iOS 14, *)
|
||||
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState, SettingsViewAction>
|
||||
|
||||
class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
|
@ -19,6 +19,5 @@ import Foundation
|
||||
@MainActor
|
||||
protocol SettingsViewModelProtocol {
|
||||
var callback: ((SettingsViewModelAction) -> Void)? { get set }
|
||||
@available(iOS 14, *)
|
||||
var context: SettingsViewModelType.Context { get }
|
||||
}
|
||||
|
@ -1,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 { }
|
@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SplashViewController" customModule="ElementX" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/app-logo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
|
||||
<rect key="frame" x="87" y="328" width="240" height="240"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="ue7-fB-5XS" secondAttribute="bottom" constant="16" id="Cip-Sc-gaF"/>
|
||||
<constraint firstItem="ue7-fB-5XS" firstAttribute="top" relation="greaterThanOrEqual" secondItem="fnl-2z-Ty3" secondAttribute="top" constant="16" id="Clt-cS-YAr"/>
|
||||
<constraint firstItem="ue7-fB-5XS" firstAttribute="centerY" secondItem="i5M-Pr-FkT" secondAttribute="centerY" id="N3w-Jf-MRA"/>
|
||||
<constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="ue7-fB-5XS" secondAttribute="trailing" constant="16" id="WfN-3K-kpr"/>
|
||||
<constraint firstItem="ue7-fB-5XS" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="ujr-SL-AyX"/>
|
||||
<constraint firstItem="ue7-fB-5XS" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="fnl-2z-Ty3" secondAttribute="leading" constant="16" id="yNl-Wu-AES"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="137.68115942028987" y="153.34821428571428"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="Images/app-logo" width="240" height="240"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
@ -1,81 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
final class SplashScreenCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let splashScreenHostingController: UIViewController
|
||||
private var splashScreenViewModel: SplashScreenViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((SplashScreenCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
let viewModel = SplashScreenViewModel()
|
||||
let view = SplashScreen(context: viewModel.context)
|
||||
splashScreenViewModel = viewModel
|
||||
splashScreenHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: splashScreenHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
splashScreenViewModel.callback = { [weak self] action in
|
||||
MXLog.debug("SplashScreenViewModel did complete with result: \(action).")
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .login:
|
||||
self.callback?(.login)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
splashScreenHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
@ -25,45 +25,24 @@ enum VideoPlayerCoordinatorAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class VideoPlayerCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
final class VideoPlayerCoordinator: CoordinatorProtocol {
|
||||
private let parameters: VideoPlayerCoordinatorParameters
|
||||
private let videoPlayerHostingController: UIViewController
|
||||
private var videoPlayerViewModel: VideoPlayerViewModelProtocol
|
||||
private var viewModel: VideoPlayerViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((VideoPlayerCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: VideoPlayerCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
|
||||
let view = VideoPlayerScreen(context: viewModel.context)
|
||||
videoPlayerViewModel = viewModel
|
||||
videoPlayerHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController)
|
||||
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
|
||||
configureAudioSession(.sharedInstance())
|
||||
|
||||
videoPlayerViewModel.callback = { [weak self] action in
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
|
||||
switch action {
|
||||
@ -73,16 +52,12 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
videoPlayerHostingController
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(VideoPlayerScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
|
||||
private func configureAudioSession(_ session: AVAudioSession) {
|
||||
do {
|
||||
try session.setCategory(.playback,
|
||||
@ -93,17 +68,4 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
|
||||
MXLog.debug("Configure audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
activityIndicator = nil
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
class SplashScreenUITests: XCTestCase {
|
||||
class OnboardingUITests: XCTestCase {
|
||||
func testInitialStateComponents() {
|
||||
let app = Application.launch()
|
||||
app.goToScreenWithIdentifier(.splash)
|
@ -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
Loading…
x
Reference in New Issue
Block a user