From ad80b91b1665fa556cff6fe13a9fd4decb1d9c78 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 22 Feb 2022 11:24:46 +0200 Subject: [PATCH] Copied over loading indicator classes and added a modal one for the login screen. --- ElementX.xcodeproj/project.pbxproj | 64 ++++++++++ ElementX/Sources/AppCoordinator.swift | 22 +++- .../AuthenticationCoordinator.swift | 8 +- .../LoginScreen/View/LoginScreen.swift | 2 +- .../Modules/HomeScreen/View/HomeScreen.swift | 2 +- .../Modules/Other/Activity/Activity.swift | 102 +++++++++++++++ .../Other/Activity/ActivityCenter.swift | 60 +++++++++ .../Other/Activity/ActivityDismissal.swift | 25 ++++ .../Other/Activity/ActivityPresentable.swift | 25 ++++ .../FullscreenLoadingActivityPresenter.swift | 67 ++++++++++ .../ToastActivityPresenter.swift | 69 ++++++++++ .../Other/Activity/ActivityRequest.swift | 28 +++++ .../LabelledActivityIndicatorView.swift | 92 ++++++++++++++ .../Activity/Toasts/RectangleToastView.swift | 87 +++++++++++++ .../Activity/Toasts/RoundedToastView.swift | 119 ++++++++++++++++++ 15 files changed, 765 insertions(+), 7 deletions(-) create mode 100644 ElementX/Sources/Modules/Other/Activity/Activity.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityCenter.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityDismissal.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityPresentable.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityPresenters/FullscreenLoadingActivityPresenter.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityPresenters/ToastActivityPresenter.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/ActivityRequest.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/LabelledActivityIndicatorView.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/Toasts/RectangleToastView.swift create mode 100644 ElementX/Sources/Modules/Other/Activity/Toasts/RoundedToastView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 875a94971..5dd62b0e4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -9,6 +9,16 @@ /* Begin PBXBuildFile section */ 182BC42027BE667200A30C33 /* RoomModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC41F27BE667200A30C33 /* RoomModelProtocol.swift */; }; 182BC42227BE6C6900A30C33 /* MockRoomModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC42127BE6C6900A30C33 /* MockRoomModel.swift */; }; + 182BC46F27C4CD6D00A30C33 /* RoundedToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */; }; + 182BC47027C4CD6D00A30C33 /* RectangleToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */; }; + 182BC47127C4CD6D00A30C33 /* ActivityPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */; }; + 182BC47227C4CD6D00A30C33 /* ActivityCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */; }; + 182BC47327C4CD6D00A30C33 /* ToastActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */; }; + 182BC47527C4CD6D00A30C33 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46C27C4CD6D00A30C33 /* Activity.swift */; }; + 182BC47627C4CD6D00A30C33 /* ActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */; }; + 182BC47727C4CD6D00A30C33 /* ActivityDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */; }; + 182BC47927C4CE2200A30C33 /* LabelledActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */; }; + 182BC47B27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */; }; 1850253F27B6918D002E6B18 /* ElementXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850253E27B6918D002E6B18 /* ElementXTests.swift */; }; 1850254927B6918D002E6B18 /* ElementXUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850254827B6918D002E6B18 /* ElementXUITests.swift */; }; 1850254B27B6918D002E6B18 /* ElementXUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850254A27B6918D002E6B18 /* ElementXUITestsLaunchTests.swift */; }; @@ -78,6 +88,16 @@ /* Begin PBXFileReference section */ 182BC41F27BE667200A30C33 /* RoomModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModelProtocol.swift; sourceTree = ""; }; 182BC42127BE6C6900A30C33 /* MockRoomModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomModel.swift; sourceTree = ""; }; + 182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedToastView.swift; sourceTree = ""; }; + 182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RectangleToastView.swift; sourceTree = ""; }; + 182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityPresentable.swift; sourceTree = ""; }; + 182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityCenter.swift; sourceTree = ""; }; + 182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastActivityPresenter.swift; sourceTree = ""; }; + 182BC46C27C4CD6D00A30C33 /* Activity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Activity.swift; sourceTree = ""; }; + 182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityRequest.swift; sourceTree = ""; }; + 182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityDismissal.swift; sourceTree = ""; }; + 182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelledActivityIndicatorView.swift; sourceTree = ""; }; + 182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingActivityPresenter.swift; sourceTree = ""; }; 184230FE27BD080000033771 /* matrix-rust-components-swift */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "matrix-rust-components-swift"; path = "../matrix-rust-components-swift"; sourceTree = ""; }; 1850252427B6918C002E6B18 /* ElementX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1850253A27B6918D002E6B18 /* ElementXTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ElementXTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -157,6 +177,39 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 182BC46327C4CD6D00A30C33 /* Activity */ = { + isa = PBXGroup; + children = ( + 182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */, + 182BC46427C4CD6D00A30C33 /* Toasts */, + 182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */, + 182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */, + 182BC46927C4CD6D00A30C33 /* ActivityPresenters */, + 182BC46C27C4CD6D00A30C33 /* Activity.swift */, + 182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */, + 182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */, + ); + path = Activity; + sourceTree = ""; + }; + 182BC46427C4CD6D00A30C33 /* Toasts */ = { + isa = PBXGroup; + children = ( + 182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */, + 182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */, + ); + path = Toasts; + sourceTree = ""; + }; + 182BC46927C4CD6D00A30C33 /* ActivityPresenters */ = { + isa = PBXGroup; + children = ( + 182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */, + 182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */, + ); + path = ActivityPresenters; + sourceTree = ""; + }; 1850251B27B6918C002E6B18 = { isa = PBXGroup; children = ( @@ -252,6 +305,7 @@ 1863A41A27BA76B900B52E4D /* Other */ = { isa = PBXGroup; children = ( + 182BC46327C4CD6D00A30C33 /* Activity */, 1863A43D27BA790000B52E4D /* Coordinator.swift */, 1863A41B27BA76B900B52E4D /* MXLog.swift */, 1863A43627BA789800B52E4D /* SwiftUI */, @@ -577,16 +631,20 @@ buildActionMask = 2147483647; files = ( 1863A44B27BA79FF00B52E4D /* RootRouterType.swift in Sources */, + 182BC47327C4CD6D00A30C33 /* ToastActivityPresenter.swift in Sources */, 1863A48C27BAA8A900B52E4D /* HomeScreenViewModel.swift in Sources */, 1863A41727BA716A00B52E4D /* KeychainControllerProtocol.swift in Sources */, 1863A45B27BA7B4700B52E4D /* LoginScreenCoordinator.swift in Sources */, + 182BC47127C4CD6D00A30C33 /* ActivityPresentable.swift in Sources */, 1863A41427BA716A00B52E4D /* AuthenticationCoordinator.swift in Sources */, 1863A43B27BA789800B52E4D /* BindableState.swift in Sources */, + 182BC47027C4CD6D00A30C33 /* RectangleToastView.swift in Sources */, 1863A42C27BA784300B52E4D /* LoginScreenViewModel.swift in Sources */, 1863A44F27BA79FF00B52E4D /* NavigationModule.swift in Sources */, 1863A43027BA784300B52E4D /* LoginScreenViewModelProtocol.swift in Sources */, 1863A48827BAA8A900B52E4D /* HomeScreenViewModelProtocol.swift in Sources */, 1863A44D27BA79FF00B52E4D /* RootRouter.swift in Sources */, + 182BC47627C4CD6D00A30C33 /* ActivityRequest.swift in Sources */, 1863A45F27BAA60300B52E4D /* SplashViewController.swift in Sources */, 1863A48A27BAA8A900B52E4D /* HomeScreen.swift in Sources */, 1863A44927BA79FF00B52E4D /* NavigationRouterStore.swift in Sources */, @@ -595,8 +653,10 @@ 1863A45027BA79FF00B52E4D /* NavigationRouterType.swift in Sources */, 182BC42027BE667200A30C33 /* RoomModelProtocol.swift in Sources */, 1863A45627BA7A7800B52E4D /* WeakDictionaryKeyReference.swift in Sources */, + 182BC47927C4CE2200A30C33 /* LabelledActivityIndicatorView.swift in Sources */, 1863A49427BAAA6700B52E4D /* RoomModel.swift in Sources */, 1850256F27B6A135002E6B18 /* AppDelegate.swift in Sources */, + 182BC47727C4CD6D00A30C33 /* ActivityDismissal.swift in Sources */, 1863A41627BA716A00B52E4D /* KeychainController.swift in Sources */, 1863A41527BA716A00B52E4D /* UserSession.swift in Sources */, 1863A43A27BA789800B52E4D /* StateStoreViewModel.swift in Sources */, @@ -605,11 +665,15 @@ 1863A48527BAA8A900B52E4D /* HomeScreenCoordinator.swift in Sources */, 1863A43F27BA790000B52E4D /* Coordinator.swift in Sources */, 1863A41C27BA76B900B52E4D /* MXLog.swift in Sources */, + 182BC46F27C4CD6D00A30C33 /* RoundedToastView.swift in Sources */, + 182BC47527C4CD6D00A30C33 /* Activity.swift in Sources */, + 182BC47B27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift in Sources */, 1863A44E27BA79FF00B52E4D /* Presentable.swift in Sources */, 182BC42227BE6C6900A30C33 /* MockRoomModel.swift in Sources */, 1850256C27B6A135002E6B18 /* AppCoordinator.swift in Sources */, 1863A43127BA784300B52E4D /* LoginScreenModels.swift in Sources */, 1863A44A27BA79FF00B52E4D /* NavigationRouter.swift in Sources */, + 182BC47227C4CD6D00A30C33 /* ActivityCenter.swift in Sources */, 1863A45927BA7A7800B52E4D /* WeakKeyDictionary.swift in Sources */, 1863A44C27BA79FF00B52E4D /* NavigationRouterStoreProtocol.swift in Sources */, ); diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index e858e4e6c..bc55d380d 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -18,6 +18,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private let keychainController: KeychainControllerProtocol private let authenticationCoordinator: AuthenticationCoordinator! + private var loadingActivity: Activity? + var childCoordinators: [Coordinator] = [] init() { @@ -47,11 +49,11 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { // MARK: - AuthenticationCoordinatorDelegate func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) { - + showLoadingIndicator() } func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) { - + hideLoadingIndicator() } func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator) { @@ -66,6 +68,9 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { // MARK: - Private private func presentHomeScreen() { + + hideLoadingIndicator() + guard let userSession = authenticationCoordinator.userSession else { fatalError("User session should be already setup at this point") } @@ -84,7 +89,18 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { navigationRouter.setRootModule(coordinator) } - private func restart() { + private func showLoadingIndicator() { + let presenter = FullscreenLoadingActivityPresenter(label: "Loading", on: self.mainNavigationController) + let request = ActivityRequest( + presenter: presenter, + dismissal: .manual + ) + + loadingActivity = ActivityCenter.shared.add(request) + } + + private func hideLoadingIndicator() { + loadingActivity = nil } } diff --git a/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift index f3aa03723..2a9e86e69 100644 --- a/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift @@ -44,8 +44,6 @@ class AuthenticationCoordinator: Coordinator { func start() { - delegate?.authenticationCoordinatorDidStartLoading(self) - let availableRestoreTokens = keychainController.restoreTokens() guard let usernameTokenTuple = availableRestoreTokens.first else { @@ -116,6 +114,9 @@ class AuthenticationCoordinator: Coordinator { } private func login(username: String, password: String, completion: @escaping (Result) -> Void) { + + delegate?.authenticationCoordinatorDidStartLoading(self) + DispatchQueue.global(qos: .background).async { [weak self] in guard let self = self else { return } @@ -136,6 +137,9 @@ class AuthenticationCoordinator: Coordinator { } private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String), completion: @escaping (Result) -> Void) { + + delegate?.authenticationCoordinatorDidStartLoading(self) + DispatchQueue.global(qos: .background).async { [weak self] in guard let self = self else { return } diff --git a/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift b/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift index 3fb86a39b..5d688ad9e 100644 --- a/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift +++ b/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift @@ -37,8 +37,8 @@ struct LoginScreen: View { .padding(.horizontal, 8.0) .navigationTitle("Login") .navigationBarTitleDisplayMode(.inline) - .navigationViewStyle(StackNavigationViewStyle()) } + .navigationViewStyle(StackNavigationViewStyle()) } } diff --git a/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift index c2108bf5a..ee92458a7 100644 --- a/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift @@ -57,7 +57,6 @@ struct HomeScreen: View { .listStyle(.plain) } .navigationBarTitleDisplayMode(.inline) - .navigationViewStyle(StackNavigationViewStyle()) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Logout") { @@ -66,6 +65,7 @@ struct HomeScreen: View { } } } + .navigationViewStyle(StackNavigationViewStyle()) } } diff --git a/ElementX/Sources/Modules/Other/Activity/Activity.swift b/ElementX/Sources/Modules/Other/Activity/Activity.swift new file mode 100644 index 000000000..5c8eaac0b --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/Activity.swift @@ -0,0 +1,102 @@ +// +// Copyright 2021 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 + +/// An `Activity` represents the state of a temporary visual indicator, such as activity indicator, 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 `Activity` may be requested by the system at the same time (e.g. global syncing vs local refresh), +/// and the `ActivityCenter` will ensure that only one activity is shown at a given time, putting the other in a pending queue. +/// +/// A client that requests an activity can specify a default timeout after which the activity is dismissed, or it has to be manually +/// responsible for dismissing it via `cancel` method, or by deallocating itself. +public class Activity { + public enum State { + case pending + case executing + case completed + } + + private let request: ActivityRequest + private let completion: () -> Void + + public private(set) var state: State + + public init(request: ActivityRequest, 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 activity, triggering any dismissal action / animation + /// + /// Note: clients can call this method directly, if they have access to the `Activity`. + /// Once cancelled, `ActivityCenter` will automatically start the next `Activity` 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 Activity { + func store(in collection: inout C) where C: RangeReplaceableCollection, C.Element == Activity { + collection.append(self) + } +} + +public extension Collection where Element == Activity { + func cancelAll() { + forEach { + $0.cancel() + } + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityCenter.swift b/ElementX/Sources/Modules/Other/Activity/ActivityCenter.swift new file mode 100644 index 000000000..9d9e0c704 --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityCenter.swift @@ -0,0 +1,60 @@ +// +// Copyright 2021 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 shared activity center with a single FIFO queue which will ensure only one activity is shown at a given time. +/// +/// `ActivityCenter` offers a `shared` center that can be used by any clients, but clients are also allowed +/// to create local `ActivityCenter` if the context requres multiple simultaneous activities. +public class ActivityCenter { + private class Weak { + weak var element: T? + init(_ element: T) { + self.element = element + } + } + + public static let shared = ActivityCenter() + private var queue = [Weak]() + + /// Add a new activity to the queue by providing a request. + /// + /// The queue will start the activity right away, if there are no currently running activities, + /// otherwise the activity will be put on hold. + public func add(_ request: ActivityRequest) -> Activity { + let activity = Activity(request: request) { [weak self] in + self?.startNextIfIdle() + } + + queue.append(Weak(activity)) + startNextIfIdle() + return activity + } + + private func startNextIfIdle() { + cleanup() + if let activity = queue.first?.element, activity.state == .pending { + activity.start() + } + } + + private func cleanup() { + queue.removeAll { + $0.element == nil || $0.element?.state == .completed + } + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityDismissal.swift b/ElementX/Sources/Modules/Other/Activity/ActivityDismissal.swift new file mode 100644 index 000000000..69405c579 --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityDismissal.swift @@ -0,0 +1,25 @@ +// +// Copyright 2021 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 an `Activity` can be dismissed +public enum ActivityDismissal { + /// The `Activity` will not manage the dismissal, but will expect the calling client to do so manually + case manual + /// The `Activity` will be automatically dismissed after `TimeInterval` + case timeout(TimeInterval) +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityPresentable.swift b/ElementX/Sources/Modules/Other/Activity/ActivityPresentable.swift new file mode 100644 index 000000000..5bb489fcc --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityPresentable.swift @@ -0,0 +1,25 @@ +// +// Copyright 2021 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 an `Activity`, and responsible for the underlying view shown on the screen. +public protocol ActivityPresentable { + /// Called when the `Activity` is started (manually or by the `ActivityCenter`) + func present() + /// Called when the `Activity` is manually cancelled or completed + func dismiss() +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/FullscreenLoadingActivityPresenter.swift b/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/FullscreenLoadingActivityPresenter.swift new file mode 100644 index 000000000..8ae1fb3df --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/FullscreenLoadingActivityPresenter.swift @@ -0,0 +1,67 @@ +// +// Copyright 2021 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 + +/// An `ActivityPresenter` responsible for showing / hiding a full-screen loading view that obscures (and thus disables) all other controls. +/// It is managed by an `Activity`, meaning the `present` and `dismiss` methods will be called when the parent `Activity` starts or completes. +class FullscreenLoadingActivityPresenter: ActivityPresentable { + private let label: String + private weak var viewController: UIViewController? + private weak var view: UIView? + + init(label: String, on viewController: UIViewController) { + self.label = label + self.viewController = viewController + } + + func present() { + let view = LabelledActivityIndicatorView(text: label) + self.view = view + + guard let window = viewController?.view.window else { + return + } + + view.translatesAutoresizingMaskIntoConstraints = false + window.addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: window.topAnchor), + view.bottomAnchor.constraint(equalTo: window.bottomAnchor), + view.leadingAnchor.constraint(equalTo: window.leadingAnchor), + view.trailingAnchor.constraint(equalTo: window.trailingAnchor) + ]) + + view.alpha = 0 + CATransaction.commit() + UIView.animate(withDuration: 0.2) { + view.alpha = 1 + } + } + + func dismiss() { + guard let view = view, view.superview != nil else { + return + } + + UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) { + view.alpha = 0 + } completion: { _ in + view.removeFromSuperview() + } + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/ToastActivityPresenter.swift b/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/ToastActivityPresenter.swift new file mode 100644 index 000000000..7d8720af6 --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityPresenters/ToastActivityPresenter.swift @@ -0,0 +1,69 @@ +// +// Copyright 2021 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 + +/// An `ActivityPresenter` responsible for showing / hiding a toast view for activity indicators or success messages. +/// It is managed by an `Activity`, meaning the `present` and `dismiss` methods will be called when the parent `Activity` starts or completes. +class ToastActivityPresenter: ActivityPresentable { + private let viewState: RoundedToastView.ViewState + private weak var navigationController: UINavigationController? + private weak var view: UIView? + + init(viewState: RoundedToastView.ViewState, navigationController: UINavigationController) { + self.viewState = viewState + self.navigationController = navigationController + } + + func present() { + guard let navigationController = navigationController else { + return + } + + let view = RoundedToastView(viewState: viewState) + self.view = view + + view.translatesAutoresizingMaskIntoConstraints = false + navigationController.view.addSubview(view) + NSLayoutConstraint.activate([ + view.centerXAnchor.constraint(equalTo: navigationController.navigationBar.centerXAnchor), + view.topAnchor.constraint(equalTo: navigationController.navigationBar.bottomAnchor) + ]) + + view.alpha = 0 + CATransaction.flush() + view.transform = .init(translationX: 0, y: 5) + + UIView.animate(withDuration: 0.2) { + view.alpha = 1 + view.transform = .identity + } + } + + func dismiss() { + guard let view = view, view.superview != nil else { + return + } + + UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) { + view.alpha = 0 + view.transform = .init(translationX: 0, y: -5) + } completion: { _ in + view.removeFromSuperview() + } + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/ActivityRequest.swift b/ElementX/Sources/Modules/Other/Activity/ActivityRequest.swift new file mode 100644 index 000000000..f009fc211 --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/ActivityRequest.swift @@ -0,0 +1,28 @@ +// +// Copyright 2021 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 request used to create an underlying `Activity`, allowing clients to only specify the visual aspects of an activity. +public struct ActivityRequest { + internal let presenter: ActivityPresentable + internal let dismissal: ActivityDismissal + + public init(presenter: ActivityPresentable, dismissal: ActivityDismissal) { + self.presenter = presenter + self.dismissal = dismissal + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/LabelledActivityIndicatorView.swift b/ElementX/Sources/Modules/Other/Activity/LabelledActivityIndicatorView.swift new file mode 100644 index 000000000..c0cf01da3 --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/LabelledActivityIndicatorView.swift @@ -0,0 +1,92 @@ +// +// Copyright 2021 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 stackBackgroundOpacity: CGFloat = 0.9 + static let stackSpacing: CGFloat = 15 + static let backgroundOpacity: CGFloat = 0.5 + } + + private let stackBackgroundView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constants.cornerRadius + view.alpha = Constants.stackBackgroundOpacity + view.backgroundColor = .gray.withAlphaComponent(0.75) + 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 = { + return UILabel() + }() + + init(text: String) { + super.init(frame: .zero) + setup(text: text) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup(text: String) { + setupStackView() + label.text = text + } + + 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) + ]) + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/Toasts/RectangleToastView.swift b/ElementX/Sources/Modules/Other/Activity/Toasts/RectangleToastView.swift new file mode 100644 index 000000000..b624b44ce --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/Toasts/RectangleToastView.swift @@ -0,0 +1,87 @@ +// +// Copyright 2021 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 = 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 = 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 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ElementX/Sources/Modules/Other/Activity/Toasts/RoundedToastView.swift b/ElementX/Sources/Modules/Other/Activity/Toasts/RoundedToastView.swift new file mode 100644 index 000000000..1c348921b --- /dev/null +++ b/ElementX/Sources/Modules/Other/Activity/Toasts/RoundedToastView.swift @@ -0,0 +1,119 @@ +// +// Copyright 2021 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 RoundedToastView: UIView { + private struct 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 shadowOffset = CGSize(width: 0, height: 4) + static let shadowRadius = CGFloat(12) + static let shadowOpacity = Float(0.1) + } + + private lazy var activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView() + indicator.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale) + indicator.startAnimating() + return indicator + }() + + private lazy var imagView: 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.spacing = 5 + return stack + }() + + private let label: UILabel = { + return UILabel() + }() + + init(viewState: ViewState) { + super.init(frame: .zero) + setup(viewState: viewState) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup(viewState: ViewState) { + setupLayer() + setupStackView() + stackView.addArrangedSubview(toastView(for: viewState.style)) + stackView.addArrangedSubview(label) + label.text = viewState.label + } + + 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) + ]) + } + + private func setupLayer() { + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = Constants.shadowOffset + layer.shadowRadius = Constants.shadowRadius + layer.shadowOpacity = Constants.shadowOpacity + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = layer.frame.height / 2 + } + + private func toastView(for style: Style) -> UIView { + switch style { + case .loading: + return activityIndicator + case .success: + imagView.image = UIImage(systemName: "checkmark.circle") + return imagView + } + } +} + +extension RoundedToastView { + enum Style { + case loading + case success + } + struct ViewState { + let style: Style + let label: String + } +}