From e1f87f67f640d3788a2e8448683f8f4077cff561 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Tue, 15 Feb 2022 10:55:06 +0000 Subject: [PATCH] Add CommonKit and Activity Signed-off-by: Andy Uhnak --- CommonKit/Common.xcconfig | 28 ++++ CommonKit/CommonKit.h | 27 ++++ CommonKit/Debug.xcconfig | 20 +++ CommonKit/Info.plist | 22 +++ CommonKit/Release.xcconfig | 20 +++ CommonKit/Source/Activity/Activity.swift | 94 +++++++++++++ .../Source/Activity/ActivityCenter.swift | 60 +++++++++ .../Source/Activity/ActivityDismissal.swift | 25 ++++ .../Source/Activity/ActivityPresentable.swift | 25 ++++ .../Source/Activity/ActivityRequest.swift | 28 ++++ .../Activity/Tests/ActivityCenterTests.swift | 55 ++++++++ .../Activity/Tests/ActivityPresenterSpy.swift | 29 ++++ .../Source/Activity/Tests/ActivityTests.swift | 127 ++++++++++++++++++ CommonKit/target.yml | 40 ++++++ CommonKit/targetUnitTests.yml | 42 ++++++ Riot/target.yml | 1 + project.yml | 4 +- 17 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 CommonKit/Common.xcconfig create mode 100644 CommonKit/CommonKit.h create mode 100644 CommonKit/Debug.xcconfig create mode 100644 CommonKit/Info.plist create mode 100644 CommonKit/Release.xcconfig create mode 100644 CommonKit/Source/Activity/Activity.swift create mode 100644 CommonKit/Source/Activity/ActivityCenter.swift create mode 100644 CommonKit/Source/Activity/ActivityDismissal.swift create mode 100644 CommonKit/Source/Activity/ActivityPresentable.swift create mode 100644 CommonKit/Source/Activity/ActivityRequest.swift create mode 100644 CommonKit/Source/Activity/Tests/ActivityCenterTests.swift create mode 100644 CommonKit/Source/Activity/Tests/ActivityPresenterSpy.swift create mode 100644 CommonKit/Source/Activity/Tests/ActivityTests.swift create mode 100644 CommonKit/target.yml create mode 100644 CommonKit/targetUnitTests.yml diff --git a/CommonKit/Common.xcconfig b/CommonKit/Common.xcconfig new file mode 100644 index 000000000..38afbcfe3 --- /dev/null +++ b/CommonKit/Common.xcconfig @@ -0,0 +1,28 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Config/AppIdentifiers.xcconfig" +#include "Config/AppVersion.xcconfig" + +PRODUCT_NAME = CommonKit +PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_IDENTIFIER).commonkit + +INFOPLIST_FILE = CommonKit/Info.plist + +SKIP_INSTALL = YES diff --git a/CommonKit/CommonKit.h b/CommonKit/CommonKit.h new file mode 100644 index 000000000..b057961e6 --- /dev/null +++ b/CommonKit/CommonKit.h @@ -0,0 +1,27 @@ +// +// 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 + +//! Project version number for CommonKit. +FOUNDATION_EXPORT double CommonKitVersionNumber; + +//! Project version string for CommonKit. +FOUNDATION_EXPORT const unsigned char CommonKitVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/CommonKit/Debug.xcconfig b/CommonKit/Debug.xcconfig new file mode 100644 index 000000000..11a7288a4 --- /dev/null +++ b/CommonKit/Debug.xcconfig @@ -0,0 +1,20 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Common.xcconfig" diff --git a/CommonKit/Info.plist b/CommonKit/Info.plist new file mode 100644 index 000000000..c0701c6d7 --- /dev/null +++ b/CommonKit/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/CommonKit/Release.xcconfig b/CommonKit/Release.xcconfig new file mode 100644 index 000000000..11a7288a4 --- /dev/null +++ b/CommonKit/Release.xcconfig @@ -0,0 +1,20 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Common.xcconfig" diff --git a/CommonKit/Source/Activity/Activity.swift b/CommonKit/Source/Activity/Activity.swift new file mode 100644 index 000000000..d72e87800 --- /dev/null +++ b/CommonKit/Source/Activity/Activity.swift @@ -0,0 +1,94 @@ +// +// 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 { + enum State { + case pending + case executing + case completed + } + + private let request: ActivityRequest + private let completion: () -> Void + + private(set) var state: State + + public init(request: ActivityRequest, completion: @escaping () -> Void) { + self.request = request + self.completion = completion + + state = .pending + } + + deinit { + cancel() + } + + 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. + 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) + } +} diff --git a/CommonKit/Source/Activity/ActivityCenter.swift b/CommonKit/Source/Activity/ActivityCenter.swift new file mode 100644 index 000000000..9d9e0c704 --- /dev/null +++ b/CommonKit/Source/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/CommonKit/Source/Activity/ActivityDismissal.swift b/CommonKit/Source/Activity/ActivityDismissal.swift new file mode 100644 index 000000000..69405c579 --- /dev/null +++ b/CommonKit/Source/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/CommonKit/Source/Activity/ActivityPresentable.swift b/CommonKit/Source/Activity/ActivityPresentable.swift new file mode 100644 index 000000000..5bb489fcc --- /dev/null +++ b/CommonKit/Source/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/CommonKit/Source/Activity/ActivityRequest.swift b/CommonKit/Source/Activity/ActivityRequest.swift new file mode 100644 index 000000000..f009fc211 --- /dev/null +++ b/CommonKit/Source/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/CommonKit/Source/Activity/Tests/ActivityCenterTests.swift b/CommonKit/Source/Activity/Tests/ActivityCenterTests.swift new file mode 100644 index 000000000..a1e375851 --- /dev/null +++ b/CommonKit/Source/Activity/Tests/ActivityCenterTests.swift @@ -0,0 +1,55 @@ +// +// 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 XCTest + +class ActivityCenterTests: XCTestCase { + var activities: [Activity]! + var center: ActivityCenter! + + override func setUp() { + activities = [] + center = ActivityCenter() + } + + func makeRequest() -> ActivityRequest { + return ActivityRequest( + presenter: ActivityPresenterSpy(), + dismissal: .manual + ) + } + + func testStartsActivityWhenAdded() { + let activity = center.add(makeRequest()) + XCTAssertEqual(activity.state, .executing) + } + + func testSecondActivityIsPending() { + center.add(makeRequest()).store(in: &activities) + let activity = center.add(makeRequest()) + XCTAssertEqual(activity.state, .pending) + } + + func testSecondActivityIsExecutingWhenFirstCompleted() { + let first = center.add(makeRequest()) + let second = center.add(makeRequest()) + + first.cancel() + + XCTAssertEqual(second.state, .executing) + } +} diff --git a/CommonKit/Source/Activity/Tests/ActivityPresenterSpy.swift b/CommonKit/Source/Activity/Tests/ActivityPresenterSpy.swift new file mode 100644 index 000000000..2a0d03249 --- /dev/null +++ b/CommonKit/Source/Activity/Tests/ActivityPresenterSpy.swift @@ -0,0 +1,29 @@ +// +// 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 + +class ActivityPresenterSpy: ActivityPresentable { + var intel = [String]() + + func present() { + intel.append(#function) + } + + func dismiss() { + intel.append(#function) + } +} diff --git a/CommonKit/Source/Activity/Tests/ActivityTests.swift b/CommonKit/Source/Activity/Tests/ActivityTests.swift new file mode 100644 index 000000000..11cc7eec2 --- /dev/null +++ b/CommonKit/Source/Activity/Tests/ActivityTests.swift @@ -0,0 +1,127 @@ +// +// 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 XCTest + +class ActivityTests: XCTestCase { + var presenter: ActivityPresenterSpy! + + override func setUp() { + super.setUp() + presenter = ActivityPresenterSpy() + } + + func makeActivity(dismissal: ActivityDismissal = .manual, callback: @escaping () -> Void = {}) -> Activity { + let request = ActivityRequest( + presenter: presenter, + dismissal: dismissal + ) + return Activity( + request: request, + completion: callback + ) + } + + // MARK: - State + + func testNewActivityIsPending() { + let activity = makeActivity() + XCTAssertEqual(activity.state, .pending) + } + + func testStartedActivityIsExecuting() { + let activity = makeActivity() + activity.start() + XCTAssertEqual(activity.state, .executing) + } + + func testCancelledActivityIsCompleted() { + let activity = makeActivity() + activity.cancel() + XCTAssertEqual(activity.state, .completed) + } + + // MARK: - Presenter + + func testStartingActivityPresentsUI() { + let activity = makeActivity() + activity.start() + XCTAssertEqual(presenter.intel, ["present()"]) + } + + func testAllowStartingOnlyOnce() { + let activity = makeActivity() + activity.start() + presenter.intel = [] + + activity.start() + + XCTAssertEqual(presenter.intel, []) + } + + func testCancellingActivityDismissesUI() { + let activity = makeActivity() + activity.start() + presenter.intel = [] + + activity.cancel() + + XCTAssertEqual(presenter.intel, ["dismiss()"]) + } + + func testAllowCancellingOnlyOnce() { + let activity = makeActivity() + activity.start() + activity.cancel() + presenter.intel = [] + + activity.cancel() + + XCTAssertEqual(presenter.intel, []) + } + + // MARK: - Dismissal + + func testDismissAfterTimeout() { + let interval: TimeInterval = 0.01 + let activity = makeActivity(dismissal: .timeout(interval)) + + activity.start() + + let exp = expectation(description: "") + DispatchQueue.main.asyncAfter(deadline: .now() + interval) { + exp.fulfill() + } + waitForExpectations(timeout: 1) + + XCTAssertEqual(activity.state, .completed) + } + + // MARK: - Completion callback + + func testTriggersCallbackWhenCompleted() { + var didComplete = false + let activity = makeActivity { + didComplete = true + } + activity.start() + + activity.cancel() + + XCTAssertTrue(didComplete) + } +} diff --git a/CommonKit/target.yml b/CommonKit/target.yml new file mode 100644 index 000000000..fad6e8375 --- /dev/null +++ b/CommonKit/target.yml @@ -0,0 +1,40 @@ +name: CommonKit + +schemes: + CommonKit: + analyze: + config: Debug + archive: + config: Release + build: + targets: + CommonKit: + - running + - testing + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + test: + config: Debug + disableMainThreadChecker: true + targets: + - CommonKitUnitTests + +targets: + CommonKit: + type: framework + platform: iOS + + configFiles: + Debug: Debug.xcconfig + Release: Release.xcconfig + + sources: + - path: . + excludes: + - "**/Tests/**" diff --git a/CommonKit/targetUnitTests.yml b/CommonKit/targetUnitTests.yml new file mode 100644 index 000000000..4a8f8a7b2 --- /dev/null +++ b/CommonKit/targetUnitTests.yml @@ -0,0 +1,42 @@ +name: CommonKitUnitTests + +schemes: + CommonKitUnitTests: + analyze: + config: Debug + archive: + config: Release + build: + targets: + CommonKitUnitTests: + - running + - testing + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + test: + config: Debug + disableMainThreadChecker: true + targets: + - CommonKitUnitTests + +targets: + CommonKitUnitTests: + type: bundle.unit-test + platform: iOS + + dependencies: + - target: CommonKit + + configFiles: + Debug: Debug.xcconfig + Release: Release.xcconfig + + sources: + - path: . + diff --git a/Riot/target.yml b/Riot/target.yml index f8f52a7da..1abcaae6c 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -35,6 +35,7 @@ targets: - target: SiriIntents - target: RiotNSE - target: DesignKit + - target: CommonKit - package: Mapbox configFiles: diff --git a/project.yml b/project.yml index dcd15f63b..7bb2ef0a0 100644 --- a/project.yml +++ b/project.yml @@ -35,9 +35,11 @@ include: - path: RiotSwiftUI/target.yml - path: RiotSwiftUI/targetUnitTests.yml - path: RiotSwiftUI/targetUITests.yml + - path: CommonKit/target.yml + - path: CommonKit/targetUnitTests.yml packages: Mapbox: url: https://github.com/maplibre/maplibre-gl-native-distribution minVersion: 5.12.2 - maxVersion: 5.13.0 \ No newline at end of file + maxVersion: 5.13.0