From 968b1d43f1aa3ce3feefd39c1384f4a6bc25c384 Mon Sep 17 00:00:00 2001 From: wfltaylor Date: Thu, 23 Jul 2020 20:54:00 +1000 Subject: [PATCH 1/5] Spin out friends logic into friends manager --- Project SF.xcodeproj/project.pbxproj | 12 ++ .../Competitions/CompetitionsController.swift | 73 +----------- Project SF/Logic/Friends/FriendsManager.swift | 104 ++++++++++++++++++ 3 files changed, 117 insertions(+), 72 deletions(-) create mode 100644 Project SF/Logic/Friends/FriendsManager.swift diff --git a/Project SF.xcodeproj/project.pbxproj b/Project SF.xcodeproj/project.pbxproj index 44ec64b..01c48e0 100644 --- a/Project SF.xcodeproj/project.pbxproj +++ b/Project SF.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ 9CCFDD2624B9753F00162B0F /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCFDD2524B9753F00162B0F /* Main.swift */; }; 9CCFDD2824B9758A00162B0F /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCFDD2724B9758A00162B0F /* TestApp.swift */; }; 9CD7F11E24B89C5000DDAD8C /* ProjectSFTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD7F11D24B89C5000DDAD8C /* ProjectSFTests.swift */; }; + 9CD90D9724C99C4A00020288 /* FriendsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD90D9624C99C4A00020288 /* FriendsManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -190,6 +191,7 @@ 9CD7F11B24B89C5000DDAD8C /* Project SFTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Project SFTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 9CD7F11D24B89C5000DDAD8C /* ProjectSFTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSFTests.swift; sourceTree = ""; }; 9CD7F11F24B89C5000DDAD8C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9CD90D9624C99C4A00020288 /* FriendsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -504,6 +506,7 @@ 9CCFDD1624B971D600162B0F /* CloudKit */, 30ABC14324C5684B0060825B /* User */, 30ABC14124C567DF0060825B /* Competitions */, + 9CD90D9524C99C3C00020288 /* Friends */, 30ABC14424C568A10060825B /* Scoring */, 30ABC14224C568210060825B /* Invitations */, 9C6C74B424BBE56800C657B0 /* HealthKit */, @@ -547,6 +550,14 @@ path = "Project SFTests"; sourceTree = ""; }; + 9CD90D9524C99C3C00020288 /* Friends */ = { + isa = PBXGroup; + children = ( + 9CD90D9624C99C4A00020288 /* FriendsManager.swift */, + ); + path = Friends; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -725,6 +736,7 @@ 30251C1B24BFC1B10058D6D2 /* CompetitorCell.swift in Sources */, 30CCF30724BED9EA00103C1E /* ActivityOverview.swift in Sources */, 30D321BA24BB65D5009CD9D0 /* NotificationSettings.swift in Sources */, + 9CD90D9724C99C4A00020288 /* FriendsManager.swift in Sources */, 30251C1E24BFE1D50058D6D2 /* PointsGraph.swift in Sources */, 30BFC8E724B8041C00DAC6D9 /* ActivityRingsView.swift in Sources */, 9C2445DC24BC714100D8F4FC /* Invitation.swift in Sources */, diff --git a/Project SF/Logic/Competitions/CompetitionsController.swift b/Project SF/Logic/Competitions/CompetitionsController.swift index 4efa70c..53c7b69 100644 --- a/Project SF/Logic/Competitions/CompetitionsController.swift +++ b/Project SF/Logic/Competitions/CompetitionsController.swift @@ -352,78 +352,7 @@ class CompetitionsController { } // MARK: Friend Discovery (should be moved out of this class) - - /// Requests permission from the user to discover their contacts. - /// - Parameter handler: The result handler. Not guaranteed to be executed on the main thread. - /// - Tag: requestDiscoveryPermission - func requestDiscoveryPermission(then handler: @escaping (Result) -> Void) { - container.requestApplicationPermission([.userDiscoverability]) { (status, error) in - if let error = error { - handler(.failure(error)) - return - } - switch status { - case .granted: - handler(.success(true)) - case .denied: - handler(.success(false)) - default: - handler(.failure(CompetitionsControllerError.unknownError)) - } - } - } - - /// Asynchronously discovers the users friends. Fails if the adequate permissions have not been granted (you can request the required permission using [requestDiscoveryPermission](x-source-tag://requestDiscoveryPermission). - /// - Parameter handler: The result handler. Not guaranteed to be executed on the main thread. - func discoverFriends(then handler: @escaping (Result<[Friend], Error>) -> Void) { - container.status(forApplicationPermission: .userDiscoverability) { [weak container] status, error in - guard let container = container else { return } - if let error = error { - handler(.failure(error)) - return - } - if case .granted = status { - container.discoverAllIdentities { identities, error in - if let error = error { - handler(.failure(error)) - return - } - guard let identities = identities else { - handler(.failure(CompetitionsControllerError.unknownError)) - return - } - - let operation = CKFetchRecordsOperation(recordIDs: identities.compactMap { $0.userRecordID }) - operation.qualityOfService = .userInitiated - operation.fetchRecordsCompletionBlock = { ckRecords, error in - if let error = error { - handler(.failure(error)) - return - } - guard let ckRecords = ckRecords else { - handler(.failure(CompetitionsControllerError.unknownError)) - return - } - let records = ckRecords - .map { UserRecord(record: $0.value) } - - let friends = records - .map { - return Friend(name: $0.username ?? "", - profilePicture: URL(string: $0.profilePictureURL ?? ""), - recordID: $0.record.recordID) - } - - handler(.success(friends)) - } - - container.sharedCloudDatabase.add(operation) - } - } else { - handler(.failure(CompetitionsControllerError.insufficientPermissions)) - } - } - } + // MARK: Helper Methods diff --git a/Project SF/Logic/Friends/FriendsManager.swift b/Project SF/Logic/Friends/FriendsManager.swift new file mode 100644 index 0000000..bfa9077 --- /dev/null +++ b/Project SF/Logic/Friends/FriendsManager.swift @@ -0,0 +1,104 @@ +// +// FriendsManager.swift +// Project SF +// +// Created by William Taylor on 23/7/20. +// + +import Foundation +import CloudKit + +class FriendsManager { + + // MARK: Properties + + private let container: CKContainer + + // MARK: Init + + init(container: CKContainer = .appDefault) { + self.container = container + } + + // MARK: Methods + + /// Requests permission from the user to discover their contacts. + /// - Parameter handler: The result handler. Not guaranteed to be executed on the main thread. + /// - Tag: requestDiscoveryPermission + func requestDiscoveryPermission(then handler: @escaping (Result) -> Void) { + container.requestApplicationPermission([.userDiscoverability]) { (status, error) in + if let error = error { + handler(.failure(error)) + return + } + switch status { + case .granted: + handler(.success(true)) + case .denied: + handler(.success(false)) + default: + handler(.failure(FriendsManagerError.unknownError)) + } + } + } + + /// Asynchronously discovers the users friends. Fails if the adequate permissions have not been granted (you can request the required permission using [requestDiscoveryPermission](x-source-tag://requestDiscoveryPermission). + /// - Parameter handler: The result handler. Not guaranteed to be executed on the main thread. + func discoverFriends(then handler: @escaping (Result<[Friend], Error>) -> Void) { + container.status(forApplicationPermission: .userDiscoverability) { [weak container] status, error in + guard let container = container else { return } + if let error = error { + handler(.failure(error)) + return + } + if case .granted = status { + container.discoverAllIdentities { identities, error in + if let error = error { + handler(.failure(error)) + return + } + guard let identities = identities else { + handler(.failure(FriendsManagerError.unknownError)) + return + } + + let operation = CKFetchRecordsOperation(recordIDs: identities.compactMap { $0.userRecordID }) + operation.qualityOfService = .userInitiated + operation.fetchRecordsCompletionBlock = { ckRecords, error in + if let error = error { + handler(.failure(error)) + return + } + guard let ckRecords = ckRecords else { + handler(.failure(FriendsManagerError.unknownError)) + return + } + let records = ckRecords + .map { UserRecord(record: $0.value) } + + let friends = records + .map { + return Friend(name: $0.username ?? "", + profilePicture: URL(string: $0.profilePictureURL ?? ""), + recordID: $0.record.recordID) + } + + handler(.success(friends)) + } + + container.sharedCloudDatabase.add(operation) + } + } else { + handler(.failure(FriendsManagerError.insufficientPermissions)) + } + } + } + + // MARK: FriendsManagerError + + enum FriendsManagerError: Error { + case insufficientPermissions + case unknownError + } + +} From a576639eb55834326761b69d6c69ea40e59c9ef0 Mon Sep 17 00:00:00 2001 From: wfltaylor Date: Thu, 23 Jul 2020 20:55:59 +1000 Subject: [PATCH 2/5] Remove unnecessary mark --- Project SF/Logic/Competitions/CompetitionsController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Project SF/Logic/Competitions/CompetitionsController.swift b/Project SF/Logic/Competitions/CompetitionsController.swift index 53c7b69..e8a51c0 100644 --- a/Project SF/Logic/Competitions/CompetitionsController.swift +++ b/Project SF/Logic/Competitions/CompetitionsController.swift @@ -351,9 +351,6 @@ class CompetitionsController { } } - // MARK: Friend Discovery (should be moved out of this class) - - // MARK: Helper Methods /// Utility method to create a zone with a randomised identifier. From c944940e1feef359369fc93f178eb0f05260973b Mon Sep 17 00:00:00 2001 From: wfltaylor Date: Fri, 24 Jul 2020 17:25:07 +1000 Subject: [PATCH 3/5] Rename competitions controller to competitions manager --- Project SF.xcodeproj/project.pbxproj | 8 ++++---- ...etitionsController.swift => CompetitionsManager.swift} | 2 +- .../Views/Tabs/Competitions/CreateCompetition.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename Project SF/Logic/Competitions/{CompetitionsController.swift => CompetitionsManager.swift} (99%) diff --git a/Project SF.xcodeproj/project.pbxproj b/Project SF.xcodeproj/project.pbxproj index 01c48e0..c87e480 100644 --- a/Project SF.xcodeproj/project.pbxproj +++ b/Project SF.xcodeproj/project.pbxproj @@ -61,7 +61,7 @@ 9C2445D824BC009F00D8F4FC /* RoundedNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6C74AB24BADD1800C657B0 /* RoundedNavigationButton.swift */; }; 9C2445DA24BC0D8E00D8F4FC /* InvitationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2445D924BC0D8E00D8F4FC /* InvitationRecord.swift */; }; 9C2445DC24BC714100D8F4FC /* Invitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2445DB24BC714100D8F4FC /* Invitation.swift */; }; - 9C338BD524BAC4F4003E5048 /* CompetitionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C338BD424BAC4F4003E5048 /* CompetitionsController.swift */; }; + 9C338BD524BAC4F4003E5048 /* CompetitionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C338BD424BAC4F4003E5048 /* CompetitionsManager.swift */; }; 9C338BD724BAD080003E5048 /* RecordKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C338BD624BAD080003E5048 /* RecordKey.swift */; }; 9C338BD924BAD0D3003E5048 /* CompetitionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C338BD824BAD0D3003E5048 /* CompetitionRecord.swift */; }; 9C39F69524C07BAA0073C9FF /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7E74A724BDC05B0032CD88 /* SignUpView.swift */; }; @@ -159,7 +159,7 @@ 9C12BBE524B9D41600C050DB /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = ""; }; 9C2445D924BC0D8E00D8F4FC /* InvitationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationRecord.swift; sourceTree = ""; }; 9C2445DB24BC714100D8F4FC /* Invitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Invitation.swift; sourceTree = ""; }; - 9C338BD424BAC4F4003E5048 /* CompetitionsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionsController.swift; sourceTree = ""; }; + 9C338BD424BAC4F4003E5048 /* CompetitionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionsManager.swift; sourceTree = ""; }; 9C338BD624BAD080003E5048 /* RecordKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordKey.swift; sourceTree = ""; }; 9C338BD824BAD0D3003E5048 /* CompetitionRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionRecord.swift; sourceTree = ""; }; 9C6C74AB24BADD1800C657B0 /* RoundedNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedNavigationButton.swift; sourceTree = ""; }; @@ -282,7 +282,7 @@ isa = PBXGroup; children = ( 9C338BD824BAD0D3003E5048 /* CompetitionRecord.swift */, - 9C338BD424BAC4F4003E5048 /* CompetitionsController.swift */, + 9C338BD424BAC4F4003E5048 /* CompetitionsManager.swift */, ); path = Competitions; sourceTree = ""; @@ -723,7 +723,7 @@ 9C8FAD4A24BAADA600571947 /* CKContainer+AppDefault.swift in Sources */, 302CF88A24BA199E00FF79D7 /* RingType.swift in Sources */, 9C2445DA24BC0D8E00D8F4FC /* InvitationRecord.swift in Sources */, - 9C338BD524BAC4F4003E5048 /* CompetitionsController.swift in Sources */, + 9C338BD524BAC4F4003E5048 /* CompetitionsManager.swift in Sources */, 9C7B4D0624B89AC700FC4456 /* View+ForegroundModifier.swift in Sources */, 9C338BD724BAD080003E5048 /* RecordKey.swift in Sources */, 30D321B924BB65D5009CD9D0 /* ImageSelectionView.swift in Sources */, diff --git a/Project SF/Logic/Competitions/CompetitionsController.swift b/Project SF/Logic/Competitions/CompetitionsManager.swift similarity index 99% rename from Project SF/Logic/Competitions/CompetitionsController.swift rename to Project SF/Logic/Competitions/CompetitionsManager.swift index e8a51c0..92e4239 100644 --- a/Project SF/Logic/Competitions/CompetitionsController.swift +++ b/Project SF/Logic/Competitions/CompetitionsManager.swift @@ -14,7 +14,7 @@ import CloudKit // TODO: Make sure all operations have their quality of service set to .userInitiated // swiftlint:disable type_body_length // NOTE: SwiftLint disable is temporary -class CompetitionsController { +class CompetitionsManager { // MARK: Properties diff --git a/Project SF/Views/Tabs/Competitions/CreateCompetition.swift b/Project SF/Views/Tabs/Competitions/CreateCompetition.swift index e0753cc..c68185a 100644 --- a/Project SF/Views/Tabs/Competitions/CreateCompetition.swift +++ b/Project SF/Views/Tabs/Competitions/CreateCompetition.swift @@ -26,7 +26,7 @@ struct CreateCompetition: View { @State var distanceGoal = "10" @State var distanceGoalInt = 10 - let competitionController = CompetitionsController() + let competitionsManager = CompetitionsManager() var body: some View { ScrollView(showsIndicators: false) { @@ -202,7 +202,7 @@ struct CreateCompetition: View { RoundedButton("Start the competition") { guard !competitionName.isEmpty else { return } - competitionController.createCompetition( + competitionsManager.createCompetition( type: .init( move: move, exercise: exercise, From 3d2caa524378d96b803a39ed03caf8f1df875047 Mon Sep 17 00:00:00 2001 From: wfltaylor Date: Fri, 24 Jul 2020 18:13:55 +1000 Subject: [PATCH 4/5] Fix errors --- Project SF.xcodeproj/project.pbxproj | 8 +- .../Competitions/CompetitionsController.swift | 95 ++ .../Competitions/CompetitionsManager.swift | 849 ++++++++++++++++-- 3 files changed, 878 insertions(+), 74 deletions(-) create mode 100644 Project SF/Logic/Competitions/CompetitionsController.swift diff --git a/Project SF.xcodeproj/project.pbxproj b/Project SF.xcodeproj/project.pbxproj index b882457..d3a4a94 100644 --- a/Project SF.xcodeproj/project.pbxproj +++ b/Project SF.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 30132FF024CA78E000CB6683 /* CompetitionStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30132FEF24CA78E000CB6683 /* CompetitionStruct.swift */; }; - 30132FF224CAB48700CB6683 /* CompetitionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30132FF124CAB48700CB6683 /* CompetitionsController.swift */; }; 3019CD9924BC9792002564AD /* PlaceBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3019CD9824BC9792002564AD /* PlaceBadgeView.swift */; }; 30251C1924BFC1A50058D6D2 /* CompetitorDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30251C1824BFC1A50058D6D2 /* CompetitorDetail.swift */; }; 30251C1B24BFC1B10058D6D2 /* CompetitorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30251C1A24BFC1B10058D6D2 /* CompetitorCell.swift */; }; @@ -92,6 +91,7 @@ 9CCFDD2824B9758A00162B0F /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCFDD2724B9758A00162B0F /* TestApp.swift */; }; 9CD7F11E24B89C5000DDAD8C /* ProjectSFTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD7F11D24B89C5000DDAD8C /* ProjectSFTests.swift */; }; 9CD90D9724C99C4A00020288 /* FriendsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD90D9624C99C4A00020288 /* FriendsManager.swift */; }; + 9CD90D9F24CACF7300020288 /* CompetitionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD90D9E24CACF7300020288 /* CompetitionsController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -106,7 +106,6 @@ /* Begin PBXFileReference section */ 30132FEF24CA78E000CB6683 /* CompetitionStruct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionStruct.swift; sourceTree = ""; }; - 30132FF124CAB48700CB6683 /* CompetitionsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionsController.swift; sourceTree = ""; }; 3019CD9824BC9792002564AD /* PlaceBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceBadgeView.swift; sourceTree = ""; }; 30251C1824BFC1A50058D6D2 /* CompetitorDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitorDetail.swift; sourceTree = ""; }; 30251C1A24BFC1B10058D6D2 /* CompetitorCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitorCell.swift; sourceTree = ""; }; @@ -198,6 +197,7 @@ 9CD7F11D24B89C5000DDAD8C /* ProjectSFTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSFTests.swift; sourceTree = ""; }; 9CD7F11F24B89C5000DDAD8C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9CD90D9624C99C4A00020288 /* FriendsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsManager.swift; sourceTree = ""; }; + 9CD90D9E24CACF7300020288 /* CompetitionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompetitionsController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -299,7 +299,7 @@ 30132FEF24CA78E000CB6683 /* CompetitionStruct.swift */, 9C338BD824BAD0D3003E5048 /* CompetitionRecord.swift */, 9C338BD424BAC4F4003E5048 /* CompetitionsManager.swift */, - 30132FF124CAB48700CB6683 /* CompetitionsController.swift */, + 9CD90D9E24CACF7300020288 /* CompetitionsController.swift */, ); path = Competitions; sourceTree = ""; @@ -722,6 +722,7 @@ 30EAB9F924C29B9D005326C8 /* AccentSettings.swift in Sources */, 30BFC8DF24B7637F00DAC6D9 /* VisualEffectView.swift in Sources */, 9CCFDD1D24B971D600162B0F /* UserRecord.swift in Sources */, + 9CD90D9F24CACF7300020288 /* CompetitionsController.swift in Sources */, 9C6C74B624BBE57B00C657B0 /* FriendStruct.swift in Sources */, 9CCFDD1D24B971D600162B0F /* UserRecord.swift in Sources */, 9C2445D824BC009F00D8F4FC /* RoundedNavigationButton.swift in Sources */, @@ -772,7 +773,6 @@ 3067713124BDE3950085F152 /* FriendDetailView.swift in Sources */, 30D321BB24BB65D5009CD9D0 /* PermissionSettings.swift in Sources */, 304F753124CAC9BF005D46BA /* FriendsManager.swift in Sources */, - 30132FF224CAB48700CB6683 /* CompetitionsController.swift in Sources */, 3050123624C0887D00E39019 /* Int+ConvertFromRangeToRange copy.swift in Sources */, 30278E9324BC553A00E87E80 /* CompetitionDetail.swift in Sources */, 30D321B824BB65D5009CD9D0 /* SettingsView.swift in Sources */, diff --git a/Project SF/Logic/Competitions/CompetitionsController.swift b/Project SF/Logic/Competitions/CompetitionsController.swift new file mode 100644 index 0000000..831ce64 --- /dev/null +++ b/Project SF/Logic/Competitions/CompetitionsController.swift @@ -0,0 +1,95 @@ +// +// CompetitionsController.swift +// Project SF +// +// Created by Christian Privitelli on 24/7/20. +// + +import Foundation + +class CompetitionsController: ObservableObject { + + var manager = CompetitionsManager() + + @Published var state = State.loading + @Published var competitions: [Competition] = [] + + /// Create a competition and update on success. + /// - Parameters: + /// - competition: A competition struct to add to CloudKit + /// - friends: An array of friends you would like to invite to the competition. + func create(competition: Competition, with friends: [Friend]) { + state = .loading + + manager.createCompetition( + type: .init( + move: competition.move, + exercise: competition.exercise, + stand: competition.stand, + steps: competition.steps, + distance: competition.distance, + stepsGoal: competition.stepsGoal, + distanceGoal: competition.distanceGoal), + title: competition.title, + endDate: competition.endDate, + friends: friends + ) { result in + DispatchQueue.main.async { + switch result { + case .success: + self.state = .idle + self.update() + case .failure(let error): + self.state = .failure(error) + self.update() + } + } + } + } + + /// Fetch latest list of competitions from CloudKit and store the result in the `competitions` array. + func update() { + state = .loading + + manager.fetchCompetitions { result in + DispatchQueue.main.async { + switch result { + case .success(let competitions): + self.state = .idle + self.competitions = competitions.map { Competition(record: $0) } + case .failure(let error): + self.state = .failure(error) + } + } + } + } + + /// Possible state of the `CompetitionsController` class. + enum State: Equatable { + case loading + case idle + case failure(Error) + + /// Make equatable to detect changes. + static func == (lhs: CompetitionsController.State, rhs: CompetitionsController.State) -> Bool { + switch (lhs, rhs) { + case (.loading, .loading), + (.idle, .idle): + return true + case (.loading, .idle), + (.loading, .failure), + (.failure, .idle), + (.idle, .loading), + (.idle, .failure), + (.failure, .loading): + return false + case (.failure(let lhsError), .failure(let rhsError)): + if lhsError.localizedDescription == rhsError.localizedDescription { + return true + } else { + return false + } + } + } + } +} diff --git a/Project SF/Logic/Competitions/CompetitionsManager.swift b/Project SF/Logic/Competitions/CompetitionsManager.swift index bb492f8..5c292bb 100644 --- a/Project SF/Logic/Competitions/CompetitionsManager.swift +++ b/Project SF/Logic/Competitions/CompetitionsManager.swift @@ -1,95 +1,804 @@ // -// CompetitionsController.swift +// CompetitionsManager.swift // Project SF // -// Created by Christian Privitelli on 24/7/20. +// Created by William Taylor on 12/7/20. // - import Foundation +import CloudKit -class CompetitionsController: ObservableObject { +// NOTE: This is currently a prototype and contains some poorly written code. +// TODO: Refactor into multiple classes +// TODO: Add proper error handling with retrying +// TODO: Make sure all operations have their quality of service set to .userInitiated +// swiftlint:disable type_body_length +// NOTE: SwiftLint disable is temporary +class CompetitionsManager { + + // MARK: Properties + + private let container: CKContainer + + // MARK: Init - var manager = CompetitionsManager() + init(container: CKContainer = .appDefault) { + self.container = container + } + + // MARK: Competitions - @Published var state = State.loading - @Published var competitions: [Competition] = [] + /// Creates a competition. + /// - Parameters: + /// - type: The competition types. + /// - endDate: The date the competition will end. + /// - friends: The friends to invite to the competition. This currently can't be changed later. + /// - handler: Called with the result of the operation. Not guaranteed to be on the main thread.. + /// + /// Internally, this creates a `CompetitionRecord` which is shared to all friend participants (with read only access). The `CompetitionRecord` contains all the competition metadata and a list of share urls pointing to `ScoreURLHolderRecord`s which, if the participant has joined the competition, contain a share url that will grant access to the participants score infomation. + func createCompetition( + type: CompetitionRecord.CompetitionTypes, + title: String, + endDate: Date, + friends: [Friend], + then handler: @escaping (Result) -> Void + ) { + // shares can't be saved to the default zone + createZone { result in + switch result { + case .success(let zone): + self.fetchShareParticipantsFrom(friends: friends) { result in + switch result { + case .success(let participants): + // create the main competition record + let competitionRecord = CompetitionRecord(recordID: CKRecord.ID(zoneID: zone.zoneID)) + competitionRecord.type = type + competitionRecord.title = title + competitionRecord.startDate = Date() + competitionRecord.endDate = endDate + + // create the main share + let share = CKShare(rootRecord: competitionRecord.record) + share.publicPermission = .none + share[CKShare.SystemFieldKey.title] = "Competition" + + for participant in participants.map({ $0.0 }) { + // no one except the creator can edit the competition metadata + participant.permission = .readOnly + share.addParticipant(participant) + } + + // save the record and the share + let operation = CKModifyRecordsOperation( + recordsToSave: [competitionRecord.record, share], + recordIDsToDelete: nil + ) + operation.qualityOfService = .userInitiated + + var savedShare: CKShare? + var shareCreationError: Error? + + operation.perRecordCompletionBlock = { record, error in + if let record = record as? CKShare { + shareCreationError = error + savedShare = record + } + } + + operation.completionBlock = { + if let error = shareCreationError { + handler(.failure(error)) + return + } + guard let savedShare = savedShare, let url = savedShare.url else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + self.inviteFriendsToCompetition(friends, inviteURL: url, zoneID: zone.zoneID) { result in + switch result { + case .success(let scoreURLHolderShareURLs): + competitionRecord.scoreURLHolderShareURLs = scoreURLHolderShareURLs.map { "\($0)" } + + let finalSaveOperation = CKModifyRecordsOperation(recordsToSave: [ + competitionRecord.record + ], + recordIDsToDelete: nil) + finalSaveOperation.qualityOfService = .userInitiated + + finalSaveOperation.modifyRecordsCompletionBlock = { _, _, error in + if let error = error { + handler(.failure(error)) + return + } + + handler(.success(())) + } + + self.container.privateCloudDatabase.add(finalSaveOperation) + case .failure(let error): + handler(.failure(error)) + } + } + } + + self.container.privateCloudDatabase.add(operation) + case .failure(let error): + handler(.failure(error)) + } + } + case .failure(let error): + handler(.failure(error)) + } + } + } + + /// Fetches pending invitations. + /// - Parameter handler: Called with the result of the operation. Not guaranteed to be on the main thread. + /// + /// Internally, this queries the public database for `InvitationRecord`s matching the users record ID. It is appropriate to store these URLs in the public database as they will only work for the recipient of the invitation. + func fetchPendingInvitations(then handler: @escaping (Result<[InvitationRecord], Error>) -> Void) { + container.fetchUserRecordID { recordID, error in + if let error = error { + handler(.failure(error)) + return + } + guard let recordID = recordID else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + // find invitations in the public database matching the users record id + let operation = CKQueryOperation(query: CKQuery(recordType: InvitationRecord.type, + predicate: NSPredicate(format: "inviteeID == %@", + recordID.recordName))) + operation.qualityOfService = .userInitiated + var invitationRecords = [InvitationRecord]() + + operation.recordFetchedBlock = { record in + invitationRecords.append(InvitationRecord(record: record)) + } + + operation.queryCompletionBlock = { _, error in + if let error = error { + handler(.failure(error)) + return + } + if invitationRecords.count == 0 { + handler(.success([])) + return + } + handler(.success(invitationRecords)) + } + + self.container.publicCloudDatabase.add(operation) + } + } - /// Create a competition and update on success. + /// Accpets a competition invitation. /// - Parameters: - /// - competition: A competition struct to add to CloudKit - /// - friends: An array of friends you would like to invite to the competition. - func create(competition: Competition, with friends: [Friend]) { - state = .loading - - manager.createCompetition( - type: .init( - move: competition.move, - exercise: competition.exercise, - stand: competition.stand, - steps: competition.steps, - distance: competition.distance, - stepsGoal: competition.stepsGoal, - distanceGoal: competition.distanceGoal), - title: competition.title, - endDate: competition.endDate, - friends: friends - ) { result in - DispatchQueue.main.async { - switch result { - case .success: - self.state = .idle - self.update() - case .failure(let error): - self.state = .failure(error) - self.update() + /// - invitation: The invitation to accept. + /// - handler: Called with the result of the operation. Not guaranteed to be on the main thread. + /// + /// Internally, this accepts both share URLs present in the invitation (the `CompetitionRecord` share and the `ScoreURLHolder` share), and modifies the `ScoreURLHolder` to contain the share url for the users score infomation. + func acceptInvitation(_ invitation: InvitationRecord, then handler: @escaping (Result) -> Void) { + guard let competitionRecordInviteURLString = invitation.competitionRecordInviteURL, + let scoreURLHolderInviteURLString = invitation.scoreURLHolderInviteURL, + let competitionRecordInviteURL = URL(string: competitionRecordInviteURLString), + let scoreURLHolderInviteURL = URL(string: scoreURLHolderInviteURLString) else { + handler(.failure(CompetitionsManagerError.missingURL)) + return + } + let metadataFetchOperation = CKFetchShareMetadataOperation(shareURLs: [competitionRecordInviteURL, + scoreURLHolderInviteURL]) + metadataFetchOperation.qualityOfService = .userInitiated + + var shareMetadata = [CKShare.Metadata]() + var errors = [Error]() + + metadataFetchOperation.perShareMetadataBlock = { _, metadata, error in + if let error = error { + errors.append(error) + return + } + guard let metadata = metadata else { return } + shareMetadata.append(metadata) + } + + metadataFetchOperation.fetchShareMetadataCompletionBlock = { error in + if let error = error { + handler(.failure(error)) + return + } + if !errors.isEmpty { + handler(.failure(CompetitionsManagerError.multiple(errors))) + } + guard let scoreURLHolderShareMetadata = shareMetadata.first(where: { $0.share.url == scoreURLHolderInviteURL }) else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + let acceptOperation = CKAcceptSharesOperation(shareMetadatas: shareMetadata) + acceptOperation.qualityOfService = .userInitiated + + acceptOperation.acceptSharesCompletionBlock = { error in + if let error = error { + handler(.failure(error)) + return + } + // TODO: Create/get the existing share url for the ScoreRecord and add it to the ScoreURLHolderRecord + self.fetchScoreRecordInfomation { result in + switch result { + case .success(let url): + let fetchURLHolderOperation = CKFetchRecordsOperation(recordIDs: [scoreURLHolderShareMetadata.rootRecordID]) + fetchURLHolderOperation.qualityOfService = .userInitiated + fetchURLHolderOperation.fetchRecordsCompletionBlock = { records, error in + if let error = error { + handler(.failure(error)) + return + } + guard let recordRaw = records?.first?.value else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + let record = ScoreURLHolderRecord(record: recordRaw) + record.isSet = true + record.url = "\(url)" + + let saveRecordOperation = CKModifyRecordsOperation(recordsToSave: [record.record], + recordIDsToDelete: nil) + saveRecordOperation.qualityOfService = .userInitiated + saveRecordOperation.modifyRecordsCompletionBlock = { _, _, error in + if let error = error { + handler(.failure(error)) + return + } + + handler(.success(())) + } + + self.container.sharedCloudDatabase.add(saveRecordOperation) + } + + self.container.sharedCloudDatabase.add(fetchURLHolderOperation) + case .failure(let error): + handler(.failure(error)) + } + } + } + + self.container.add(acceptOperation) + } + + container.add(metadataFetchOperation) + } + + /// Fetches all competitions that the user is a participant in, including ones the created. + /// - Parameter handler: Called with the result of the operation. Not guaranteed to be on the main thread. + /// + /// Internally, this queries both the private and the shared database for competitions. + func fetchCompetitions(then handler: @escaping (Result<[CompetitionRecord], Error>) -> Void) { + // TODO: Should be optimized with caching + let dispatchGroup = DispatchGroup() + + var competitionRecords = [CompetitionRecord]() + var errors = [Error]() + + let recordFetchHandler = { (result: Result<[CompetitionRecord], Error>) in + dispatchGroup.leave() + switch result { + case .success(let records): + competitionRecords.append(contentsOf: records) + case .failure(let error): + errors.append(error) + } + } + + dispatchGroup.enter() + fetchCompetitionsFromDatabaseWithScope(.private, then: recordFetchHandler) + dispatchGroup.enter() + fetchCompetitionsFromDatabaseWithScope(.shared, then: recordFetchHandler) + + dispatchGroup.notify(queue: .main) { + if competitionRecords.isEmpty, !errors.isEmpty { + handler(.failure(CompetitionsManagerError.multiple(errors))) + } else { + handler(.success(competitionRecords)) + } + } + } + + func fetchScoreRecordsFor( + _ competition: CompetitionRecord, + then handler: @escaping (Result<[ScoreRecord], Error>) -> Void + ) { + let dispatchGroup = DispatchGroup() + + var personalScoreRecordResult: Result? + var otherScoreRecordsResult: Result<[ScoreRecord], Error>? + + dispatchGroup.enter() + fetchPersonalScoreRecord { result in + personalScoreRecordResult = result + dispatchGroup.leave() + } + + dispatchGroup.enter() + fetchExternalScoreRecordsFor(competition) { result in + otherScoreRecordsResult = result + dispatchGroup.leave() + } + + dispatchGroup.notify(queue: .main) { + guard let personalScoreRecordResult = personalScoreRecordResult, + let otherScoreRecordsResult = otherScoreRecordsResult else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + var records = [ScoreRecord]() + switch personalScoreRecordResult { + case .success(let record): + records.append(record) + case .failure(let error): + handler(.failure(error)) + return + } + + switch otherScoreRecordsResult { + case .success(let scoreRecords): + records.append(contentsOf: scoreRecords) + case .failure(let error): + handler(.failure(error)) + return + } + + handler(.success(records)) + } + } + + // MARK: Helper Methods + + /// Utility method to create a zone with a randomised identifier. + private func createZone(then handler: @escaping (Result) -> Void) { + let zone = CKRecordZone(zoneName: UUID().uuidString) + let zoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [zone], recordZoneIDsToDelete: nil) + zoneOperation.qualityOfService = .userInitiated + + zoneOperation.modifyRecordZonesCompletionBlock = { recordZones, _, error in + if let error = error { + handler(.failure(error)) + return + } + guard let zone = recordZones?.first else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + handler(.success(zone)) + } + + container.privateCloudDatabase.add(zoneOperation) + } + + /// Invites friends to a competition with a designated `inviteURL`. The share that the `inviteURL` corresponds to must already have the friend as a participant. + /// + /// This creates a CKShare for each invitee that points to a new `ScoreURLHolderRecord`. The invitee can edit this when the accept their invite to contain a share URL that links to their personal `ScoreRecord`. + private func inviteFriendsToCompetition( + _ friends: [Friend], + inviteURL: URL, + zoneID: CKRecordZone.ID, + then handler: @escaping (Result<[URL], Error>) -> Void + ) { + fetchShareParticipantsFrom(friends: friends) { result in + switch result { + case .success(let participantsAndFriends): + var urlHolders = [ScoreURLHolderRecord]() + var shares = [CKShare]() + var friendToShare = [Friend: CKShare]() + + for (participant, friend) in participantsAndFriends { + let urlHolder = ScoreURLHolderRecord(recordID: CKRecord.ID(zoneID: zoneID)) + urlHolder.isSet = false + urlHolders.append(urlHolder) + + let share = CKShare(rootRecord: urlHolder.record) + share.publicPermission = .none + + for (participantToAdd, _) in participantsAndFriends { + if participant == participantToAdd { + participant.permission = .readWrite + } else { + participant.permission = .readOnly + } + share.addParticipant(participant) + } + + shares.append(share) + + friendToShare[friend] = share + } + + var recordsToSave = urlHolders.map { $0.record } + recordsToSave.append(contentsOf: shares) + + let saveScoreURLHoldersOperation = CKModifyRecordsOperation(recordsToSave: recordsToSave, + recordIDsToDelete: nil) + saveScoreURLHoldersOperation.qualityOfService = .userInitiated + + var errors = [Error]() + + saveScoreURLHoldersOperation.perRecordCompletionBlock = { record, error in + if let error = error { + errors.append(error) + } + } + + saveScoreURLHoldersOperation.completionBlock = { + if !errors.isEmpty { + handler(.failure(CompetitionsManagerError.multiple(errors))) + return + } + var inviteRecords = [InvitationRecord]() + + for friend in friends { + guard let share = friendToShare[friend], let shareURL = share.url else { continue } + let inviteRecord = InvitationRecord() + inviteRecord.competitionRecordInviteURL = "\(inviteURL)" + inviteRecord.scoreURLHolderInviteURL = "\(shareURL)" + inviteRecord.inviteeID = friend.recordID.recordName + + inviteRecords.append(inviteRecord) + } + + let saveInvitationsOperations = CKModifyRecordsOperation(recordsToSave: inviteRecords.map({ $0.record }), + recordIDsToDelete: nil) + + saveInvitationsOperations.modifyRecordsCompletionBlock = { _, _, error in + if let error = error { + handler(.failure(error)) + return + } + + handler(.success(shares.compactMap { $0.url })) + } + + self.container.publicCloudDatabase.add(saveInvitationsOperations) + } + + self.container.privateCloudDatabase.add(saveScoreURLHoldersOperation) + case .failure(let error): + handler(.failure(error)) + } + } + } + + /// Fetches share participants that can be used with a CKShare. + private func fetchShareParticipantsFrom( + friends: [Friend], + then handler: @escaping (Result<[(CKShare.Participant, Friend)], Error>) -> Void + ) { + let friendForUserRecordID: [CKRecord.ID: Friend] = Dictionary(uniqueKeysWithValues: + friends.map { (key: $0.recordID, value: $0) }) + print(friends) + let friendLookupInfomation = friends.map { CKUserIdentity.LookupInfo(userRecordID: $0.recordID) } + let participantLookupOperation = CKFetchShareParticipantsOperation(userIdentityLookupInfos: + friendLookupInfomation) + participantLookupOperation.qualityOfService = .userInitiated + + var participants = [CKShare.Participant]() + + participantLookupOperation.shareParticipantFetchedBlock = { participant in + participants.append(participant) + } + + participantLookupOperation.fetchShareParticipantsCompletionBlock = { error in + if let error = error, participants.count == 0 { + handler(.failure(error)) + return + } + + let returnValue: [(CKShare.Participant, Friend)] = participants + .compactMap { participant in + if let id = participant.userIdentity.userRecordID, let friend = friendForUserRecordID[id] { + return (participant, friend) + } else { + return nil + } + } + + handler(.success(returnValue)) + } + self.container.add(participantLookupOperation) + } + + private func fetchCompetitionsFromDatabaseWithScope( + _ scope: CKDatabase.Scope, + then handler: @escaping (Result<[CompetitionRecord], Error>) -> Void + ) { + let database = container.database(with: scope) + let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation() + + fetchZonesOperation.qualityOfService = .userInitiated + fetchZonesOperation.fetchRecordZonesCompletionBlock = { recordZones, error in + if let error = error { + handler(.failure(error)) + return + } + guard let zones = recordZones?.map({ $0.value }) else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + guard !zones.isEmpty else { + handler(.success([])) + return + } + + let dispatchGroup = DispatchGroup() + + var competitions = [CompetitionRecord]() + var errors = [Error]() + + for zone in zones { + dispatchGroup.enter() + let fetchRecordOperation = CKQueryOperation(query: CKQuery(recordType: CompetitionRecord.type, + predicate: NSPredicate(value: true))) + fetchRecordOperation.qualityOfService = .userInitiated + fetchRecordOperation.zoneID = zone.zoneID + fetchRecordOperation.recordFetchedBlock = { record in + competitions.append(CompetitionRecord(record: record)) + } + fetchRecordOperation.queryCompletionBlock = { _, error in + defer { dispatchGroup.leave() } + if let error = error { + errors.append(error) + return + } + } + + database.add(fetchRecordOperation) + } + + dispatchGroup.notify(queue: .main) { + if !errors.isEmpty, competitions.isEmpty { + handler(.failure(CompetitionsManagerError.multiple(errors))) + return + } + handler(.success(competitions)) + } + } + + database.add(fetchZonesOperation) + } + + private func fetchScoreRecordInfomation( + then handler: @escaping (Result<(shareURL: URL, recordID: CKRecord.ID), Error>) -> Void + ) { + // TODO: Add caching + + let fetchUserRecordOperation = CKFetchRecordsOperation.fetchCurrentUserRecordOperation() + fetchUserRecordOperation.fetchRecordsCompletionBlock = { records, error in + if let error = error { + handler(.failure(error)) + return + } + guard let userRecordRaw = records?.first?.value else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + let userRecord = UserRecord(record: userRecordRaw) + + if let shareURLString = userRecord.scoreRecordPublicShareURL, + let shareURL = URL(string: shareURLString), + let scoreRecordZoneName = userRecord.scoreRecordZoneName, + let scoreRecordRecordName = userRecord.scoreRecordRecordName { + let recordID = CKRecord.ID(recordName: scoreRecordRecordName, + zoneID: CKRecordZone.ID(zoneName: scoreRecordZoneName)) + handler(.success((shareURL, recordID))) + } else { + self.createScoreRecord { result in + switch result { + case .success((let shareURL, let recordID)): + handler(.success((shareURL, recordID))) + case .failure(let error): + handler(.failure(error)) + } } } } + + container.privateCloudDatabase.add(fetchUserRecordOperation) } - /// Fetch latest list of competitions from CloudKit and store the result in the `competitions` array. - func update() { - state = .loading + /// Creates a score record for the user. Should only be called if the score record doesn't already exist. + private func createScoreRecord( + then handler: @escaping (Result<(shareURL: URL, recordID: CKRecord.ID), Error>) -> Void + ) { + self.createZone { result in + switch result { + case .success(let zone): + + let scoreRecord = ScoreRecord(recordID: CKRecord.ID(zoneID: zone.zoneID)) + + let share = CKShare(rootRecord: scoreRecord.record) + share.publicPermission = .readOnly + + let recordsToSave = [scoreRecord.record, share] + let saveOperation = CKModifyRecordsOperation(recordsToSave: recordsToSave, + recordIDsToDelete: nil) + + saveOperation.modifyRecordsCompletionBlock = { _, _, error in + if let error = error { + handler(.failure(error)) + return + } + guard let shareURL = share.url else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + let userRecordFetchOperation = CKFetchRecordsOperation.fetchCurrentUserRecordOperation() + userRecordFetchOperation.fetchRecordsCompletionBlock = { records, error in + if let error = error { + handler(.failure(error)) + return + } + guard let userRecordRaw = records?.first?.value else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + let userRecord = UserRecord(record: userRecordRaw) + + userRecord.scoreRecordZoneName = scoreRecord.record.recordID.zoneID.zoneName + userRecord.scoreRecordRecordName = scoreRecord.record.recordID.recordName + userRecord.scoreRecordPublicShareURL = "\(shareURL)" + + let userRecordSaveOperation = CKModifyRecordsOperation(recordsToSave: [userRecord.record], + recordIDsToDelete: nil) + userRecordSaveOperation.modifyRecordsCompletionBlock = { _, _, error in + if let error = error { + handler(.failure(error)) + return + } + + handler(.success((shareURL, scoreRecord.record.recordID))) + } + + self.container.privateCloudDatabase.add(userRecordSaveOperation) + } + + self.container.privateCloudDatabase.add(userRecordFetchOperation) + } + + self.container.privateCloudDatabase.add(saveOperation) + case .failure(let error): + handler(.failure(error)) + } + } + } + + private func fetchExternalScoreRecordsFor( + _ competition: CompetitionRecord, + then handler: @escaping (Result<[ScoreRecord], Error>) -> Void + ) { + // TODO: Add caching + + guard let scoreURLHolderShareURLStrings = competition.scoreURLHolderShareURLs else { + handler(.success([])) + return + } + let urls = scoreURLHolderShareURLStrings.compactMap { URL(string: $0) } + + let shareMetadataFetchOperation = CKFetchShareMetadataOperation(shareURLs: urls) + shareMetadataFetchOperation.qualityOfService = .userInitiated - manager.fetchCompetitions { result in - DispatchQueue.main.async { - switch result { - case .success(let competitions): - self.state = .idle - self.competitions = competitions.map { Competition(record: $0) } - case .failure(let error): - self.state = .failure(error) + var metadatas = [CKShare.Metadata]() + var errors = [Error]() + + shareMetadataFetchOperation.perShareMetadataBlock = { _, metadata, error in + if let error = error { + errors.append(error) + return + } + if let metadata = metadata { + metadatas.append(metadata) + } + } + shareMetadataFetchOperation.fetchShareMetadataCompletionBlock = { error in + if let error = error { + handler(.failure(error)) + return + } + if metadatas.isEmpty, !errors.isEmpty { + handler(.failure(CompetitionsManagerError.multiple(errors))) + return + } + + var sharesNeedingAcceptance = [CKShare.Metadata]() + + for metadata in metadatas where metadata.participantStatus == .pending { + sharesNeedingAcceptance.append(metadata) + } + + func handleAllSharesSuccessfullyAccepted() { + let competitionAuthorID = competition.record.creatorUserRecordID + + let fetchRecordsOperation = CKFetchRecordsOperation(recordIDs: metadatas.map { $0.rootRecordID }) + fetchRecordsOperation.fetchRecordsCompletionBlock = { records, error in + if let error = error { + handler(.failure(error)) + return + } + + guard let records = records?.map({ $0.value }) else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + let scoreRecords = records + // prevent competition creator from faking ScoreRecords + .filter { $0.creatorUserRecordID == competitionAuthorID } + .map { ScoreRecord(record: $0) } + + handler(.success(scoreRecords)) + } + + self.container.sharedCloudDatabase.add(fetchRecordsOperation) + } + + if sharesNeedingAcceptance.isEmpty { + handleAllSharesSuccessfullyAccepted() + } else { + let acceptSharesOperation = CKAcceptSharesOperation(shareMetadatas: sharesNeedingAcceptance) + acceptSharesOperation.acceptSharesCompletionBlock = { error in + if let error = error { + handler(.failure(error)) + return + } + handleAllSharesSuccessfullyAccepted() } + self.container.add(acceptSharesOperation) } } + + container.add(shareMetadataFetchOperation) } - /// Possible state of the `CompetitionsController` class. - enum State: Equatable { - case loading - case idle - case failure(Error) - - /// Make equatable to detect changes. - static func == (lhs: CompetitionsController.State, rhs: CompetitionsController.State) -> Bool { - switch (lhs, rhs) { - case (.loading, .loading), - (.idle, .idle): - return true - case (.loading, .idle), - (.loading, .failure), - (.failure, .idle), - (.idle, .loading), - (.idle, .failure), - (.failure, .loading): - return false - case (.failure(let lhsError), .failure(let rhsError)): - if lhsError.localizedDescription == rhsError.localizedDescription { - return true - } else { - return false + private func fetchPersonalScoreRecord(then handler: @escaping (Result) -> Void) { + fetchScoreRecordInfomation { result in + switch result { + case .success((_, let recordID)): + let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordID]) + fetchOperation.qualityOfService = .userInitiated + + fetchOperation.fetchRecordsCompletionBlock = { records, error in + if let error = error { + handler(.failure(error)) + return + } + guard let record = records?.first?.value else { + handler(.failure(CompetitionsManagerError.unknownError)) + return + } + + let scoreRecord = ScoreRecord(record: record) + + handler(.success(scoreRecord)) } + + self.container.privateCloudDatabase.add(fetchOperation) + case .failure(let error): + handler(.failure(error)) } } } -} \ No newline at end of file + + // MARK: Competition Manager Error + + enum CompetitionsManagerError: Error { + case unknownError + case insufficientPermissions + case missingURL + case multiple([Error]) + } +} From 786d926bbd1d4cb761cce6aca4223664b2f5a86d Mon Sep 17 00:00:00 2001 From: wfltaylor Date: Fri, 24 Jul 2020 18:19:49 +1000 Subject: [PATCH 5/5] Fix spacing issue --- Project SF/Logic/Competitions/CompetitionsManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project SF/Logic/Competitions/CompetitionsManager.swift b/Project SF/Logic/Competitions/CompetitionsManager.swift index 5c292bb..c28a1b2 100644 --- a/Project SF/Logic/Competitions/CompetitionsManager.swift +++ b/Project SF/Logic/Competitions/CompetitionsManager.swift @@ -4,6 +4,7 @@ // // Created by William Taylor on 12/7/20. // + import Foundation import CloudKit